/**
 * For the full copyright and license information, please view the
 * docs/licenses/LICENSE.txt file that was distributed with this source code.
 */

import AutoCompleteSearch, {InputAutoCompleteSearchConfig} from '@components/auto-complete-search';
import ComponentsMap from '@components/components-map';
import ConfirmModal from '@components/modal';
// @ts-ignore-next-line
import Bloodhound from 'typeahead.js';
import {isUndefined} from '@components/typeguard';

const EntitySearchInputMap = ComponentsMap.entitySearchInput;

type RemoveFunction = (item: any) => void;
type SelectFunction = ($node: JQuery, item: any) => void;
type SuggestionFunction = (entity: any) => string;
export interface EntitySearchInputOptions extends OptionsObject {
  prototypeTemplate: string,
  prototypeIndex: string,
  prototypeMapping: OptionsObject,
  identifierField: string;
  allowDelete: boolean,
  dataLimit: number,
  minLength: number,
  remoteUrl: string,
  filterSelected: boolean,
  filteredIdentities: Array<string>,
  removeModal: ModalOptions,
  searchInputSelector: string,
  entitiesContainerSelector: string,
  listContainerSelector: string,
  entityItemSelector: string,
  entityDeleteSelector: string,
  emptyStateSelector: string,
  queryWildcard: string,
  onRemovedContent: RemoveFunction | undefined,
  onSelectedContent: SelectFunction | undefined,
  suggestionTemplate: SuggestionFunction | undefined,
  extraQueryParams?: () => Record<string, string>,
}
export interface ModalOptions extends OptionsObject {
  id: string;
  title: string;
  message: string;
  apply: string;
  cancel: string;
  buttonClass: string;
}

/**
 * This component is used to search and select one or several entities, it uses the AutoSearchComplete
 * component which displays a list of suggestion based on an API returned response. Then when
 * an element is selected it is added to the selection container relying on the prototype template provided.
 *
 * This component is used with EntitySearchInputType forms, and is tightly linked to the content of this
 * twig file src/PrestaShopBundle/Resources/views/Admin/TwigTemplateForm/entity_search_input.html.twig
 *
 * The default content of the collection is an EntityItemType with a simple default template but you can
 * either override it in a theme or create your own entity type if you need to customize the behaviour.
 */
export default class EntitySearchInput {
  private readonly $entitySearchInputContainer: JQuery;

  private readonly $entitySearchInput: JQuery;

  private readonly $entitiesContainer: JQuery;

  private $listContainer: JQuery;

  private $emptyState: JQuery;

  private readonly options!: EntitySearchInputOptions;

  private entityRemoteSource!: Bloodhound;

  private autoSearch!: AutoCompleteSearch;

  constructor($entitySearchInputContainer: JQuery, options: OptionsObject) {
    this.$entitySearchInputContainer = $entitySearchInputContainer;
    this.options = <EntitySearchInputOptions>{};
    this.buildOptions(options);

    this.$entitySearchInput = $(this.options.searchInputSelector, this.$entitySearchInputContainer);
    this.$entitiesContainer = $(this.options.entitiesContainerSelector, this.$entitySearchInputContainer);
    this.$listContainer = $(this.options.listContainerSelector, this.$entitySearchInputContainer);
    this.$emptyState = $(this.options.emptyStateSelector, this.$entitySearchInputContainer);

    this.buildRemoteSource();
    this.buildAutoCompleteSearch();
    this.buildActions();
    this.updateEmptyState();
  }

  /**
   * Force selected values, the input is an array of object that must match the format from
   * the API if you want the selected entities to be correctly displayed.
   *
   * @param values {Array<any>}
   */
  setValues(values: any[]): void {
    this.clearSelectedItems();
    if (!values || values.length <= 0) {
      return;
    }

    values.forEach((value: any) => {
      this.appendSelectedItem(value);
    });
  }

  /**
   * Append the item to the selection, respecting the configured limit so if limit is already reached the item is not
   * added.
   *
   * @param newItem
   *
   * @return boolean
   */
  addItem(newItem: any): boolean {
    return this.appendSelectedItem(newItem);
  }

  /**
   * @param optionName
   */
  getOption(optionName: string): any {
    return this.options[optionName];
  }

  /**
   * @param {string} optionName
   * @param {unknown} value
   */
  setOption(optionName: string, value: unknown): void {
    this.options[optionName] = value;

    // Apply special options to components when needed
    if (optionName === 'remoteUrl' && this.entityRemoteSource) {
      (<Record<string, any>> this.entityRemoteSource).remote.url = this.options.remoteUrl;
    }
  }

  /**
   * @param {Object} options
   */
  private buildOptions(options: OptionsObject): void {
    const inputOptions = options || {};
    const defaultOptions: OptionsObject = {
      suggestionField: 'name',
      prototypeTemplate: undefined,
      prototypeIndex: '__index__',
      prototypeMapping: {
        id: '__id__',
        name: '__name__',
        image: '__image__',
      },
      identifierField: 'id',
      allowDelete: true,
      dataLimit: 0,
      minLength: 2,
      remoteUrl: undefined,
      filterSelected: true,
      filteredIdentities: [],

      removeModal: {
        id: 'modal-confirm-remove-entity',
        title: 'Delete item',
        message: 'Are you sure you want to delete this item?',
        apply: 'Delete',
        cancel: 'Cancel',
        buttonClass: 'btn-danger',
      },

      // Most of the previous config are configurable via the EntitySearchInputForm options, the following ones are only
      // overridable via js config (as long as you use the default template)
      searchInputSelector: EntitySearchInputMap.searchInputSelector,
      entitiesContainerSelector: EntitySearchInputMap.entitiesContainerSelector,
      listContainerSelector: EntitySearchInputMap.listContainerSelector,
      entityItemSelector: EntitySearchInputMap.entityItemSelector,
      entityDeleteSelector: EntitySearchInputMap.entityDeleteSelector,
      emptyStateSelector: EntitySearchInputMap.emptyStateSelector,
      queryWildcard: '__QUERY__',

      // These are configurable callbacks
      onRemovedContent: undefined,
      onSelectedContent: undefined,
      responseTransformer: (response: any) => response || [],

      // Template function
      suggestionTemplate: undefined,
      extraQueryParams: undefined,
    };

    Object.keys(defaultOptions).forEach((optionName) => {
      // This gets the proper value for each option, respecting the priority: input > data-attribute > default
      this.initOption(optionName, inputOptions, defaultOptions[optionName]);
    });

    // Cast all IDs into string to avoid not matching because of different types
    this.options.filteredIdentities = this.options.filteredIdentities.map(String);
  }

  /**
   * Init the option value, the input config has the more priority. It overrides the data attribute option
   * (if present), finally a default value is used (if defined).
   *
   * @param {string} optionName
   * @param {Object} inputOptions
   * @param {*|undefined} defaultOption
   */
  private initOption(optionName: string, inputOptions: OptionsObject, defaultOption: any = undefined): void {
    if (Object.prototype.hasOwnProperty.call(inputOptions, optionName)) {
      this.options[optionName] = inputOptions[optionName];
    } else if (typeof this.$entitySearchInputContainer.data(optionName) !== 'undefined') {
      this.options[optionName] = this.$entitySearchInputContainer.data(optionName);
    } else {
      this.options[optionName] = defaultOption;
    }
  }

  private buildActions(): void {
    // Always check for click even if it is useless when allowDelete options is false, it can be changed dynamically
    $(this.$entitiesContainer).on('click', this.options.entityDeleteSelector, (event) => {
      if (!this.options.allowDelete) {
        return;
      }

      const $entity = $(event.target).closest(this.options.entityItemSelector);

      const modal = new (ConfirmModal as any)(
        {
          id: this.options.removeModal.id,
          confirmTitle: this.options.removeModal.title,
          confirmMessage: this.options.removeModal.message,
          closeButtonLabel: this.options.removeModal.cancel,
          confirmButtonLabel: this.options.removeModal.apply,
          confirmButtonClass: this.options.removeModal.buttonClass,
          closable: true,
        },
        () => {
          $entity.remove();
          this.updateEmptyState();
          if (typeof this.options.onRemovedContent !== 'undefined') {
            this.options.onRemovedContent($entity);
          }
        },
      );
      modal.show();
    });

    // For now adapt the display based on the allowDelete option
    const $entityDelete = $(this.options.entityDeleteSelector, this.$entitiesContainer);
    //'!!' converts option to bool (because if its 1 or 0, jquery toggle works differently than with true/false)
    $entityDelete.toggle(!!this.options.allowDelete);
  }

  /**
   * Build the AutoCompleteSearch component
   */
  private buildAutoCompleteSearch(): void {
    const autoSearchConfig: InputAutoCompleteSearchConfig = {
      source: this.entityRemoteSource,
      dataLimit: this.options.dataLimit,
      value: this.options.identifierField,
      minLength: this.options.minLength,
      templates: {
        suggestion: (entity: any) => this.showSuggestion(entity),
      },
      onSelect: (selectedItem: any) => {
        // When limit is one we cannot select additional elements so we replace them instead
        if (this.options.dataLimit === 1) {
          return this.replaceSelectedItem(selectedItem);
        }
        return this.appendSelectedItem(selectedItem);
      },
    };

    // The search feature may be disabled so the search input won't be present
    if (this.$entitySearchInput.length) {
      this.autoSearch = new AutoCompleteSearch(
        this.$entitySearchInput,
        autoSearchConfig,
      );
    }
  }

  private showSuggestion(entity: any): string {
    if (!isUndefined(this.options.suggestionTemplate)) {
      return this.options.suggestionTemplate(entity);
    }

    let entityImage = '';

    if (Object.prototype.hasOwnProperty.call(entity, 'image')) {
      entityImage = `<img src="${entity.image}" /> `;
    }

    return `<div class="search-suggestion">${entityImage}${entity[this.options.suggestionField]}</div>`;
  }

  /**
   * Build the Bloodhound remote source which will call the API. The placeholder to
   * inject the query search parameter is __QUERY__
   *
   * @returns {Bloodhound}
   */
  private buildRemoteSource(): void {
    this.entityRemoteSource = new Bloodhound({
      datumTokenizer: Bloodhound.tokenizers.whitespace,
      queryTokenizer: Bloodhound.tokenizers.whitespace,
      identify(obj: any) {
        return obj[this.options.identifierField];
      },
      remote: {
        url: this.options.remoteUrl,
        replace: (query: string, searchPhrase: string) => {
          // need to replace wildcard manually, because here we are overriding the default replace function
          const url = query.replace(this.options.queryWildcard, searchPhrase);

          if (!isUndefined(this.options.extraQueryParams)) {
            // this allows appending extra parameters to the query, such as shopId
            const extraParams = this.options.extraQueryParams();
            const encodedExtraParams = Object
              .keys(extraParams)
              .map((key: string) => `${key}=${encodeURIComponent(extraParams[key])}`)
              .join('&');

            return `${url}&${encodedExtraParams}`;
          }

          return url;
        },
        cache: false,
        transform: (response: any) => {
          if (!response) {
            return [];
          }
          const transformedResponse = this.options.responseTransformer(response);
          const selectedIds: string[] = this.getSelectedIds();
          const suggestedItems: any[] = [];
          transformedResponse.forEach((responseItem: any) => {
            // Force casting to string to avoid inequality with number IDs because of type
            const responseIdentifier: string = String(responseItem[this.options.identifierField]);
            const isIdContained = this.options.filterSelected && selectedIds.includes(responseIdentifier);
            const isFiltered = this.options.filteredIdentities.includes(responseIdentifier);

            if (!isIdContained && !isFiltered) {
              suggestedItems.push(responseItem);
            }
          });

          return suggestedItems;
        },
      },
    });
  }

  /**
   * Removes selected items.
   */
  private clearSelectedItems(): void {
    this.$entitiesContainer.empty();
    this.updateEmptyState();
  }

  /**
   * When the component is configured to have only one selected element on each selection
   * the previous selection is removed and then replaced.
   *
   * @param selectedItem {Object}
   * @returns {boolean}
   */
  private replaceSelectedItem(selectedItem: any): boolean {
    this.clearSelectedItems();
    this.addSelectedContentToContainer(selectedItem);

    return true;
  }

  /**
   * When the component is configured to have more than one selected item on each selection
   * the item is added to the list.
   *
   * @param selectedItem {Object}
   * @returns {boolean}
   */
  private appendSelectedItem(selectedItem: any): boolean {
    // If collection length is up to limit, return
    const $entityItems = $(this.options.entityItemSelector, this.$entitiesContainer);

    if (this.options.dataLimit !== 0 && $entityItems.length >= this.options.dataLimit) {
      return false;
    }

    this.addSelectedContentToContainer(selectedItem);

    return true;
  }

  private updateEmptyState(): void {
    const $entityItems = $(this.options.entityItemSelector, this.$entitiesContainer);
    this.$emptyState.toggle($entityItems.length === 0);
    this.$listContainer.toggle($entityItems.length !== 0);
  }

  /**
   * Add the selected content to the selection container, the HTML is generated based on the render that relies on the
   * prototype template and mapping, and finally the rendered selection is added to the list.
   *
   * @param {Object} selectedItem
   */
  private addSelectedContentToContainer(selectedItem: any): void {
    const $entityItems = $(this.options.entityItemSelector, this.$entitiesContainer);
    const newIndex = $entityItems.length ? this.getIndexFromItem($entityItems.last()) + 1 : 0;
    const selectedHtml = this.renderSelected(selectedItem, newIndex);

    const $selectedNode = $(selectedHtml);
    const $entityDelete = $(this.options.entityDeleteSelector, $selectedNode);
    $entityDelete.toggle(!!this.options.allowDelete);

    this.$entitiesContainer.append($selectedNode);

    if (typeof this.options.onSelectedContent !== 'undefined') {
      this.options.onSelectedContent($selectedNode, selectedItem);
    }
    this.updateEmptyState();
  }

  /**
   * Try and find the index of an element in the collection by parsing its inputs names which should look like:
   *
   * form[collection][0][name], form[collection][1][id] => we aim to extract the 1
   *
   * We search for the name matching the configured identifier, and extract its index. This is important because
   * when you edit a collection, you can add then remove then add an element again, the indexes are not gonna follow
   * so you cannot rely on just the index from element order. Which is why it is more accurate to parse the index that
   * was used when the element has been rendered for the first time.
   *
   * If we can't find anything we use the order index as fallback though.
   *
   * @param {JQuery} $item
   *
   * @return number
   */
  private getIndexFromItem($item: JQuery): number {
    // By default use the position index
    let index = $item.index();

    // Try to find an input which names contains [1][id] (where 1 is the index, and id the identifier)
    const identifierNameRegexp: string = `\\[(\\d+)\\]\\[${this.options.identifierField}\\]`;
    const inputs = $item.find('input');
    inputs.each((inputIndex: number, input: HTMLInputElement): void => {
      const matches = input.name.match(identifierNameRegexp);

      // Extract the index from the input name, if it is found and is a number use it as the index
      if (matches && matches.length > 0) {
        const foundIndex = parseInt(matches[1], 10);

        if (!Number.isNaN(foundIndex)) {
          index = foundIndex;
        }
      }
    });

    return index;
  }

  /**
   * Render the selected element, this will be appended in the selection list (ul), prototypeTemplate is used as the
   * base the we rely on prototypeMapping to replace every placeholders in the template by their mapping value in the
   * provided entity.
   *
   * @param {Object} entity
   * @param {number} index
   *
   * @returns {string}
   */
  private renderSelected(entity: any, index: number): string {
    let template = this.options.prototypeTemplate.replace(new RegExp(this.options.prototypeIndex, 'g'), String(index));

    Object.keys(this.options.prototypeMapping).forEach((fieldName) => {
      const fieldValue = entity[fieldName] || '';
      template = template.replace(new RegExp(this.options.prototypeMapping[fieldName], 'g'), fieldValue);
    });

    return template;
  }

  /**
   * Parses the selection container and extract the IDs this allows to filter the already selected items.
   *
   * @private
   */
  private getSelectedIds(): string[] {
    const selectedIds: string[] = [];
    const selectedChildren = $(this.options.entityItemSelector, this.$entitiesContainer);
    selectedChildren.each((index: number, selectedChild: HTMLElement) => {
      const identifierNameRegexp: string = `\\[${this.options.identifierField}\\]`;
      const inputs = $(selectedChild).find('input');
      inputs.each((inputIndex: number, input: HTMLInputElement): void => {
        if (input.name.match(identifierNameRegexp)) {
          selectedIds.push(input.value);
        }
      });
    });

    return selectedIds;
  }
}
