/*
 * SPDX-License-Identifier: Apache-2.0
 *
 * The OpenSearch Contributors require contributions made to
 * this file be licensed under the Apache-2.0 license or a
 * compatible open source license.
 *
 * Modifications Copyright OpenSearch Contributors. See
 * GitHub history for details.
 */

/*
 * Licensed to Elasticsearch B.V. under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch B.V. licenses this file to you 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 React, { Component, HTMLAttributes, ReactNode, memo } from 'react';
import classNames from 'classnames';
import { CommonProps } from '../../common';
import {
  OuiSelectableListItem,
  OuiSelectableListItemProps,
} from './selectable_list_item';
import { OuiHighlight } from '../../highlight';
import { OuiSelectableOption } from '../selectable_option';
import AutoSizer from 'react-virtualized-auto-sizer';
import {
  FixedSizeList,
  ListProps,
  ListChildComponentProps as ReactWindowListChildComponentProps,
  areEqual,
} from 'react-window';

interface ListChildComponentProps<T>
  extends ReactWindowListChildComponentProps {
  data: Array<OuiSelectableOption<T>>;
}

// Consumer Configurable Props via `OuiSelectable.listProps`
export type OuiSelectableOptionsListProps = CommonProps &
  HTMLAttributes<HTMLDivElement> & {
    /**
     * The index of the option to be highlighted as pseudo-focused;
     * Good for use when only one selection is allowed and needing to open
     * directly to that option
     */
    activeOptionIndex?: number;
    /**
     *  The height of each option in pixels. Defaults to `32`
     */
    rowHeight: number;
    /**
     * Show the check/cross selection indicator icons
     */
    showIcons?: boolean;
    singleSelection?: 'always' | boolean;
    /**
     * Any props to send specifically to the react-window `FixedSizeList`
     */
    windowProps?: Partial<ListProps>;
    /**
     * Adds a border around the list to indicate the bounds;
     * Useful when the list scrolls, otherwise use your own container
     */
    bordered?: boolean;
    /**
     * When enabled by setting to either `true` or passing custom text,
     * shows a hollow badge as an append (far right) when the item is focused.
     * The default content when `true` is `↩ to select/deselect/include/exclude`
     */
    onFocusBadge?: OuiSelectableListItemProps['onFocusBadge'];
  };

export type OuiSelectableListProps<T> = OuiSelectableOptionsListProps & {
  /**
   * All possible options
   */
  options: Array<OuiSelectableOption<T>>;
  /**
   * Filtered options list (if applicable)
   */
  visibleOptions?: Array<OuiSelectableOption<T>>;
  /**
   * Search value to highlight on the option render
   */
  searchValue: string;
  /**
   * Returns the array of options with altered checked state
   */
  onOptionClick: (options: Array<OuiSelectableOption<T>>) => void;
  /**
   * Custom render for the label portion of the option;
   * Takes (option, searchValue), returns ReactNode
   */
  renderOption?: (
    option: OuiSelectableOption<T>,
    searchValue: string
  ) => ReactNode;
  /**
   * Sets the max height in pixels or pass `full` to allow
   * the whole group to fill the height of its container and
   * allows the list grow as well
   */
  height?: number | 'full';
  /**
   * Allow cycling through the on, off and undefined state of option.checked
   * and not just on and undefined
   */
  allowExclusions?: boolean;
  searchable?: boolean;
  makeOptionId: (index: number | undefined) => string;
  listId: string;
  setActiveOptionIndex: (index: number, cb?: () => void) => void;
};

export class OuiSelectableList<T> extends Component<OuiSelectableListProps<T>> {
  static defaultProps = {
    rowHeight: 32,
    searchValue: '',
  };

  listRef: FixedSizeList | null = null;
  listBoxRef: HTMLUListElement | null = null;

  setListRef = (ref: FixedSizeList | null) => {
    this.listRef = ref;

    if (ref && this.props.activeOptionIndex) {
      ref.scrollToItem(this.props.activeOptionIndex, 'auto');
    }
  };

  removeScrollableTabStop = (ref: HTMLDivElement | null) => {
    // Firefox adds a tab stop for scrollable containers
    // We handle this inside so need to stop firefox from doing its thing
    if (ref) {
      ref.setAttribute('tabindex', '-1');
    }
  };

  setListBoxRef = (ref: HTMLUListElement | null) => {
    this.listBoxRef = ref;
    const {
      listId,
      searchable,
      singleSelection,
      'aria-label': ariaLabel,
      'aria-labelledby': ariaLabelledby,
      'aria-describedby': ariaDescribedby,
    } = this.props;

    if (ref) {
      ref.setAttribute('id', listId);
      ref.setAttribute('role', 'listbox');

      if (searchable !== true) {
        ref.setAttribute('tabindex', '0');

        if (singleSelection !== 'always' && singleSelection !== true) {
          ref.setAttribute('aria-multiselectable', 'true');
        }
      }

      if (typeof ariaLabel === 'string') {
        ref.setAttribute('aria-label', ariaLabel);
      } else if (typeof ariaLabelledby === 'string') {
        ref.setAttribute('aria-labelledby', ariaLabelledby);
      }

      if (typeof ariaDescribedby === 'string') {
        ref.setAttribute('aria-labelledby', ariaDescribedby);
      }
    }
  };

  componentDidUpdate() {
    const { activeOptionIndex } = this.props;

    if (this.listBoxRef && this.props.searchable !== true) {
      this.listBoxRef.setAttribute(
        'aria-activedescendant',
        `${this.props.makeOptionId(activeOptionIndex)}`
      );
    }

    if (this.listRef && typeof this.props.activeOptionIndex !== 'undefined') {
      this.listRef.scrollToItem(this.props.activeOptionIndex, 'auto');
    }
  }

  constructor(props: OuiSelectableListProps<T>) {
    super(props);
  }

  ListRow = memo(({ data, index, style }: ListChildComponentProps<T>) => {
    const option = data[index];
    const {
      label,
      isGroupLabel,
      checked,
      disabled,
      prepend,
      append,
      ref,
      key,
      searchableLabel,
      ...optionRest
    } = option;

    if (isGroupLabel) {
      return (
        <li
          role="presentation"
          className="ouiSelectableList__groupLabel"
          style={style}
          // @ts-ignore complex
          {...(optionRest as HTMLAttributes<HTMLLIElement>)}>
          {prepend}
          {label}
          {append}
        </li>
      );
    }

    const labelCount = data.filter((option) => option.isGroupLabel).length;

    return (
      <OuiSelectableListItem
        id={this.props.makeOptionId(index)}
        style={style}
        key={key || label.toLowerCase()}
        onMouseDown={() => {
          this.props.setActiveOptionIndex(index);
        }}
        onClick={() => this.onAddOrRemoveOption(option)}
        ref={ref ? ref.bind(null, index) : undefined}
        isFocused={this.props.activeOptionIndex === index}
        title={searchableLabel || label}
        checked={checked}
        disabled={disabled}
        prepend={prepend}
        append={append}
        aria-posinset={index + 1 - labelCount}
        aria-setsize={data.length - labelCount}
        onFocusBadge={this.props.onFocusBadge}
        allowExclusions={this.props.allowExclusions}
        showIcons={this.props.showIcons}
        {...(optionRest as OuiSelectableListItemProps)}>
        {this.props.renderOption ? (
          this.props.renderOption(option, this.props.searchValue)
        ) : (
          <OuiHighlight search={this.props.searchValue}>{label}</OuiHighlight>
        )}
      </OuiSelectableListItem>
    );
  }, areEqual);

  render() {
    const {
      className,
      options,
      searchValue,
      onOptionClick,
      renderOption,
      height: forcedHeight,
      windowProps,
      rowHeight,
      activeOptionIndex,
      makeOptionId,
      showIcons,
      singleSelection,
      visibleOptions,
      allowExclusions,
      bordered,
      searchable,
      onFocusBadge,
      listId,
      setActiveOptionIndex,
      'aria-label': ariaLabel,
      'aria-labelledby': ariaLabelledby,
      'aria-describedby': ariaDescribedby,
      ...rest
    } = this.props;

    const optionArray = visibleOptions || options;

    const heightIsFull = forcedHeight === 'full';

    let calculatedHeight = (heightIsFull ? false : forcedHeight) as
      | false
      | number
      | undefined;

    // If calculatedHeight is still undefined, then calculate it
    if (calculatedHeight === undefined) {
      const maxVisibleOptions = 7;
      const numVisibleOptions = optionArray.length;
      const numVisibleMoreThanMax = optionArray.length > maxVisibleOptions;

      if (numVisibleMoreThanMax) {
        // Show only half of the last one to indicate there's more to scroll to
        calculatedHeight = (maxVisibleOptions - 0.5) * rowHeight;
      } else {
        calculatedHeight = numVisibleOptions * rowHeight;
      }
    }

    const classes = classNames(
      'ouiSelectableList',
      {
        'ouiSelectableList-fullHeight': heightIsFull,
        'ouiSelectableList-bordered': bordered,
      },
      className
    );

    return (
      <div className={classes} {...rest}>
        <AutoSizer disableHeight={!heightIsFull}>
          {({ width, height }) => (
            <FixedSizeList
              ref={this.setListRef}
              outerRef={this.removeScrollableTabStop}
              className="ouiSelectableList__list"
              data-skip-axe="scrollable-region-focusable"
              width={width}
              height={calculatedHeight || height}
              itemCount={optionArray.length}
              itemData={optionArray}
              itemSize={rowHeight}
              innerElementType="ul"
              innerRef={this.setListBoxRef}
              {...windowProps}>
              {this.ListRow}
            </FixedSizeList>
          )}
        </AutoSizer>
      </div>
    );
  }

  onAddOrRemoveOption = (option: OuiSelectableOption<T>) => {
    if (option.disabled) {
      return;
    }

    const { allowExclusions, options, visibleOptions = options } = this.props;

    this.props.setActiveOptionIndex(
      visibleOptions.findIndex(({ label }) => label === option.label),
      () => {
        if (option.checked === 'on' && allowExclusions) {
          this.onExcludeOption(option);
        } else if (option.checked === 'on' || option.checked === 'off') {
          this.onRemoveOption(option);
        } else {
          this.onAddOption(option);
        }
      }
    );
  };

  private onAddOption = (addedOption: OuiSelectableOption<T>) => {
    const { onOptionClick, options, singleSelection } = this.props;

    const updatedOptions = options.map((option) => {
      // if singleSelection is enabled, uncheck any selected option(s)
      const updatedOption = { ...option };
      if (singleSelection) {
        delete updatedOption.checked;
      }

      // if this is the now-selected option, check it
      if (option === addedOption) {
        updatedOption.checked = 'on';
      }

      return updatedOption;
    });

    onOptionClick(updatedOptions);
  };

  private onRemoveOption = (removedOption: OuiSelectableOption<T>) => {
    const { onOptionClick, singleSelection, options } = this.props;

    const updatedOptions = options.map((option) => {
      const updatedOption = { ...option };

      if (option === removedOption && singleSelection !== 'always') {
        delete updatedOption.checked;
      }

      return updatedOption;
    });

    onOptionClick(updatedOptions);
  };

  private onExcludeOption = (excludedOption: OuiSelectableOption<T>) => {
    const { onOptionClick, options } = this.props;
    excludedOption.checked = 'off';

    const updatedOptions = options.map((option) => {
      const updatedOption = { ...option };

      if (option === excludedOption) {
        updatedOption.checked = 'off';
      }

      return updatedOption;
    });

    onOptionClick(updatedOptions);
  };
}