// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import React from 'react';

import {FilterOptionsState} from '@material-ui/core';
import Autocomplete, {
  AutocompleteChangeDetails, AutocompleteChangeReason,
  AutocompleteRenderGetTagProps, AutocompleteRenderInputParams,
  AutocompleteRenderOptionState,
} from '@material-ui/core/Autocomplete';
import Chip, {ChipProps} from '@material-ui/core/Chip';
import TextField from '@material-ui/core/TextField';
import {Value} from '@material-ui/core/useAutocomplete';

export const MAX_AUTOCOMPLETE_OPTIONS = 100;

interface AutocompleteProps<T> {
  label: string;
  options: T[];
  value?: Value<T, boolean, false, true>;
  fixedValues?: T[];
  inputType?: React.InputHTMLAttributes<unknown>['type'];
  multiple?: boolean;
  placeholder?: string;
  onChange?: (
    event: React.SyntheticEvent,
    value: Value<T, boolean, false, true>,
    reason: AutocompleteChangeReason,
    details?: AutocompleteChangeDetails<T>
  ) => void;
  getOptionDescription?: (option: T) => string;
  getOptionLabel?: (option: T) => string;
}

/**
 * A wrapper around Material UI Autocomplete that customizes and extends it for
 * Monorail's theme and options. Adds support for:
 * - Fixed values that render as disabled chips.
 * - Option descriptions that render alongside the option labels.
 * - Matching on word boundaries in both the labels and descriptions.
 * - Highlighting of the matching substrings.
 * @return Autocomplete instance with Monorail-specific properties set.
 */
export function ReactAutocomplete<T>(
  {
    label, options, value = undefined, fixedValues = [], inputType = 'text',
    multiple = false, placeholder = '', onChange = () => {},
    getOptionDescription = () => '', getOptionLabel = (o) => String(o)
  }: AutocompleteProps<T>
): React.ReactNode {
  value = value || (multiple ? [] : '');

  return <Autocomplete
    id={label}
    autoHighlight
    autoSelect
    filterOptions={_filterOptions(getOptionDescription)}
    filterSelectedOptions={multiple}
    freeSolo
    getOptionLabel={getOptionLabel}
    multiple={multiple}
    onChange={_onChange(fixedValues, multiple, onChange)}
    onKeyDown={_onKeyDown}
    options={options}
    renderInput={_renderInput(inputType, placeholder)}
    renderOption={_renderOption(getOptionDescription, getOptionLabel)}
    renderTags={_renderTags(fixedValues, getOptionLabel)}
    style={{width: 'var(--mr-edit-field-width)'}}
    value={multiple ? [...fixedValues, ...value] : value}
  />;
}

/**
 * Modifies the default option matching behavior to match on all Regex word
 * boundaries and to match on both label and description.
 * @param getOptionDescription Function to get the description for an option.
 * @return The text for a given option.
 */
function _filterOptions<T>(getOptionDescription: (option: T) => string) {
  return (
    options: T[],
    {inputValue, getOptionLabel}: FilterOptionsState<T>
  ): T[] => {
    if (!inputValue.length) {
      return [];
    }
    const regex = _matchRegex(inputValue);
    const predicate = (option: T) => {
      return getOptionLabel(option).match(regex) ||
        getOptionDescription(option).match(regex);
    }
    return options.filter(predicate).slice(0, MAX_AUTOCOMPLETE_OPTIONS);
  }
}

/**
 * Computes an onChange handler for Autocomplete. Adds logic to make sure
 * fixedValues are preserved and wraps whatever onChange handler the parent
 * passed in.
 * @param fixedValues Values that display in the edit field but can't be
 *   edited by the user. Usually set by filter rules in Monorail.
 * @param multiple Whether this input takes multiple values or not.
 * @param onChange onChange property passed in by parent, used to sync value
 *   changes to parent.
 * @return Function that's run on Autocomplete changes.
 */
function _onChange<T, Multiple, DisableClearable, FreeSolo>(
  fixedValues: T[],
  multiple: Multiple,
  onChange: (
    event: React.SyntheticEvent,
    value: Value<T, Multiple, DisableClearable, FreeSolo>,
    reason: AutocompleteChangeReason,
    details?: AutocompleteChangeDetails<T>
  ) => void,
) {
  return (
    event: React.SyntheticEvent,
    newValue: Value<T, Multiple, DisableClearable, FreeSolo>,
    reason: AutocompleteChangeReason,
    details?: AutocompleteChangeDetails<T>
  ): void => {
    // Ensure that fixed values can't be removed.
    if (multiple) {
      newValue = newValue.filter((option: T) => !fixedValues.includes(option));
    }

    // Propagate onChange callback.
    onChange(event, newValue, reason, details);
  }
}

/**
 * Custom keydown handler.
 * @param e Keyboard event.
 */
function _onKeyDown(e: React.KeyboardEvent) {
  // Convert spaces to Enter events to allow users to type space to create new
  // chips.
  if (e.key === ' ') {
    e.key = 'Enter';
  }
}

/**
 * @param inputType A valid HTML 5 input type for the `input` element.
 * @param placeholder Placeholder text for the input.
 * @return A function that renders the input element used by
 *   ReactAutocomplete.
 */
function _renderInput(inputType = 'text', placeholder = ''):
    (params: AutocompleteRenderInputParams) => React.ReactNode {
  return (params: AutocompleteRenderInputParams): React.ReactNode =>
    <TextField
      {...params} variant="standard" size="small"
      type={inputType} placeholder={placeholder}
    />;
}

/**
 * Renders a single instance of an option for Autocomplete.
 * @param getOptionDescription Function to get the description text shown.
 * @param getOptionLabel Function to get the name of the option shown to the
 *   user.
 * @return ReactNode containing the JSX to be rendered.
 */
function _renderOption<T>(
  getOptionDescription: (option: T) => string,
  getOptionLabel: (option: T) => string
): React.ReactNode {
  return (
    props: React.HTMLAttributes<HTMLLIElement>,
    option: T,
    {inputValue}: AutocompleteRenderOptionState
  ): React.ReactNode => {
    // Render the option label.
    const label = getOptionLabel(option);
    const matchValue = label.match(_matchRegex(inputValue));
    let optionTemplate = <>{label}</>;
    if (matchValue) {
      // Highlight the matching text.
      optionTemplate = <>
        {matchValue[1]}
        <strong>{matchValue[2]}</strong>
        {matchValue[3]}
      </>;
    }

    // Render the option description.
    const description = getOptionDescription(option);
    const matchDescription =
      description && description.match(_matchRegex(inputValue));
    let descriptionTemplate = <>{description}</>;
    if (matchDescription) {
      // Highlight the matching text.
      descriptionTemplate = <>
        {matchDescription[1]}
        <strong>{matchDescription[2]}</strong>
        {matchDescription[3]}
      </>;
    }

    // Put the label and description together into one <li>.
    return <li
      {...props}
      className={`${props.className} autocomplete-option`}
      style={{display: 'flex', flexDirection: 'row', wordWrap: 'break-word'}}
    >
      <span style={{display: 'block', width: (description ? '40%' : '100%')}}>
        {optionTemplate}
      </span>
      {description &&
        <span style={{display: 'block', boxSizing: 'border-box',
            paddingLeft: '8px', width: '60%'}}>
          {descriptionTemplate}
        </span>
      }
    </li>;
  };
}

/**
 * Helper to render the Chips elements used by Autocomplete. Ensures that
 * fixedValues are disabled.
 * @param fixedValues Undeleteable values in an issue usually set by filter
 *   rules.
 * @param getOptionLabel Function to compute text for the option.
 * @return Function to render the ReactNode for all the chips.
 */
function _renderTags<T>(
  fixedValues: T[], getOptionLabel: (option: T) => string
) {
  return (
    value: T[],
    getTagProps: AutocompleteRenderGetTagProps
  ): React.ReactNode => {
    return value.map((option, index) => {
      const props: ChipProps = {...getTagProps({index})};
      const disabled = fixedValues.includes(option);
      if (disabled) {
        delete props.onDelete;
      }

      const label = getOptionLabel(option);
      return <Chip
        {...props}
        key={label}
        label={label}
        disabled={disabled}
        size="small"
      />;
    });
  }
}

/**
 * Generates a RegExp to match autocomplete values.
 * @param needle The string the user is searching for.
 * @return A RegExp to find matching values.
 */
function _matchRegex(needle: string): RegExp {
  // This code copied from ac.js.
  // Since we use needle to build a regular expression, we need to escape RE
  // characters. We match '-', '{', '$' and others in the needle and convert
  // them into "\-", "\{", "\$".
  const regexForRegexCharacters = /([\^*+\-\$\\\{\}\(\)\[\]\#?\.])/g;
  const modifiedPrefix = needle.replace(regexForRegexCharacters, '\\$1');

  // Match the modifiedPrefix anywhere as long as it is either at the very
  // beginning "Th" -> "The Hobbit", or comes immediately after a word separator
  // such as "Ga" -> "The-Great-Gatsby".
  const patternRegex = '^(.*\\W)?(' + modifiedPrefix + ')(.*)';
  return new RegExp(patternRegex, 'i' /* ignore case */);
}
