import { Debouncer } from '@polymer/polymer/lib/utils/debounce.js';
import { timeOut } from '@polymer/polymer/lib/utils/async.js';
import { ComboBoxPlaceholder } from '@vaadin/combo-box/src/vaadin-combo-box-placeholder.js';

window.Vaadin.Flow.comboBoxConnector = {}
window.Vaadin.Flow.comboBoxConnector.initLazy = (comboBox) => {
  // Check whether the connector was already initialized for the ComboBox
  if (comboBox.$connector) {
    return;
  }

  comboBox.$connector = {};

  // holds pageIndex -> callback pairs of subsequent indexes (current active range)
  const pageCallbacks = {};
  let cache = {};
  let lastFilter = '';
  const placeHolder = new window.Vaadin.ComboBoxPlaceholder();

  const serverFacade = (() => {
    // Private variables
    let lastFilterSentToServer = '';
    let dataCommunicatorResetNeeded = false;

    // Public methods
    const needsDataCommunicatorReset = () => (dataCommunicatorResetNeeded = true);
    const getLastFilterSentToServer = () => lastFilterSentToServer;
    const requestData = (startIndex, endIndex, params) => {
      const count = endIndex - startIndex;
      const filter = params.filter;

      comboBox.$server.setRequestedRange(startIndex, count, filter);
      lastFilterSentToServer = filter;
      if (dataCommunicatorResetNeeded) {
        comboBox.$server.resetDataCommunicator();
        dataCommunicatorResetNeeded = false;
      }
    };

    return {
      needsDataCommunicatorReset,
      getLastFilterSentToServer,
      requestData
    };
  })();

  const clearPageCallbacks = (pages = Object.keys(pageCallbacks)) => {
    // Flush and empty the existing requests
    pages.forEach((page) => {
      pageCallbacks[page]([], comboBox.size);
      delete pageCallbacks[page];

      // Empty the comboBox's internal cache without invoking observers by filling
      // the filteredItems array with placeholders (comboBox will request for data when it
      // encounters a placeholder)
      const pageStart = parseInt(page) * comboBox.pageSize;
      const pageEnd = pageStart + comboBox.pageSize;
      const end = Math.min(pageEnd, comboBox.filteredItems.length);
      for (let i = pageStart; i < end; i++) {
        comboBox.filteredItems[i] = placeHolder;
      }
    });
  };

  comboBox.dataProvider = function (params, callback) {
    if (params.pageSize != comboBox.pageSize) {
      throw 'Invalid pageSize';
    }

    if (comboBox._clientSideFilter) {
      // For clientside filter we first make sure we have all data which we also
      // filter based on comboBox.filter. While later we only filter clientside data.

      if (cache[0]) {
        performClientSideFilter(cache[0], params.filter, callback);
        return;
      } else {
        // If client side filter is enabled then we need to first ask all data
        // and filter it on client side, otherwise next time when user will
        // input another filter, eg. continue to type, the local cache will be only
        // what was received for the first filter, which may not be the whole
        // data from server (keep in mind that client side filter is enabled only
        // when the items count does not exceed one page).
        params.filter = '';
      }
    }

    const filterChanged = params.filter !== lastFilter;
    if (filterChanged) {
      cache = {};
      lastFilter = params.filter;
      this._filterDebouncer = Debouncer.debounce(this._filterDebouncer, timeOut.after(500), () => {
        if (serverFacade.getLastFilterSentToServer() === params.filter) {
          // Fixes the case when the filter changes
          // to something else and back to the original value
          // within debounce timeout, and the
          // DataCommunicator thinks it doesn't need to send data
          serverFacade.needsDataCommunicatorReset();
        }
        if (params.filter !== lastFilter) {
          throw new Error("Expected params.filter to be '" + lastFilter + "' but was '" + params.filter + "'");
        }
        // Remove the debouncer before clearing page callbacks.
        // This makes sure that they are executed.
        this._filterDebouncer = undefined;
        // Call the method again after debounce.
        clearPageCallbacks();
        comboBox.dataProvider(params, callback);
      });
      return;
    }

    // Postpone the execution of new callbacks if there is an active debouncer.
    // They will be executed when the page callbacks are cleared within the debouncer.
    if (this._filterDebouncer) {
      pageCallbacks[params.page] = callback;
      return;
    }

    if (cache[params.page]) {
      // This may happen after skipping pages by scrolling fast
      commitPage(params.page, callback);
    } else {
      pageCallbacks[params.page] = callback;
      const maxRangeCount = Math.max(params.pageSize * 2, 500); // Max item count in active range
      const activePages = Object.keys(pageCallbacks).map((page) => parseInt(page));
      const rangeMin = Math.min(...activePages);
      const rangeMax = Math.max(...activePages);

      if (activePages.length * params.pageSize > maxRangeCount) {
        if (params.page === rangeMin) {
          clearPageCallbacks([String(rangeMax)]);
        } else {
          clearPageCallbacks([String(rangeMin)]);
        }
        comboBox.dataProvider(params, callback);
      } else if (rangeMax - rangeMin + 1 !== activePages.length) {
        // Wasn't a sequential page index, clear the cache so combo-box will request for new pages
        clearPageCallbacks();
      } else {
        // The requested page was sequential, extend the requested range
        const startIndex = params.pageSize * rangeMin;
        const endIndex = params.pageSize * (rangeMax + 1);

        serverFacade.requestData(startIndex, endIndex, params);
      }
    }
  };

  comboBox.$connector.clear = (start, length) => {
    const firstPageToClear = Math.floor(start / comboBox.pageSize);
    const numberOfPagesToClear = Math.ceil(length / comboBox.pageSize);

    for (let i = firstPageToClear; i < firstPageToClear + numberOfPagesToClear; i++) {
      delete cache[i];
    }
  };

  comboBox.$connector.filter = (item, filter) => {
    filter = filter ? filter.toString().toLowerCase() : '';
    return comboBox._getItemLabel(item, comboBox.itemLabelPath).toString().toLowerCase().indexOf(filter) > -1;
  };

  comboBox.$connector.set = (index, items, filter) => {
    if (filter != serverFacade.getLastFilterSentToServer()) {
      return;
    }

    if (index % comboBox.pageSize != 0) {
      throw 'Got new data to index ' + index + ' which is not aligned with the page size of ' + comboBox.pageSize;
    }

    if (index === 0 && items.length === 0 && pageCallbacks[0]) {
      // Makes sure that the dataProvider callback is called even when server
      // returns empty data set (no items match the filter).
      cache[0] = [];
      return;
    }

    const firstPageToSet = index / comboBox.pageSize;
    const updatedPageCount = Math.ceil(items.length / comboBox.pageSize);

    for (let i = 0; i < updatedPageCount; i++) {
      let page = firstPageToSet + i;
      let slice = items.slice(i * comboBox.pageSize, (i + 1) * comboBox.pageSize);

      cache[page] = slice;
    }
  };

  comboBox.$connector.updateData = (items) => {
    const itemsMap = new Map(items.map((item) => [item.key, item]));

    comboBox.filteredItems = comboBox.filteredItems.map((item) => {
      return itemsMap.get(item.key) || item;
    });
  };

  comboBox.$connector.updateSize = function (newSize) {
    if (!comboBox._clientSideFilter) {
      // FIXME: It may be that this size set is unnecessary, since when
      // providing data to combobox via callback we may use data's size.
      // However, if this size reflect the whole data size, including
      // data not fetched yet into client side, and combobox expect it
      // to be set as such, the at least, we don't need it in case the
      // filter is clientSide only, since it'll increase the height of
      // the popup at only at first user filter to this size, while the
      // filtered items count are less.
      comboBox.size = newSize;
    }
  };

  comboBox.$connector.reset = function () {
    clearPageCallbacks();
    cache = {};
    comboBox.clearCache();
  };

  comboBox.$connector.confirm = function (id, filter) {
    if (filter != serverFacade.getLastFilterSentToServer()) {
      return;
    }

    // We're done applying changes from this batch, resolve pending
    // callbacks
    let activePages = Object.getOwnPropertyNames(pageCallbacks);
    for (let i = 0; i < activePages.length; i++) {
      let page = activePages[i];

      if (cache[page]) {
        commitPage(page, pageCallbacks[page]);
      }
    }

    // Let server know we're done
    comboBox.$server.confirmUpdate(id);
  };

  const commitPage = function (page, callback) {
    let data = cache[page];

    if (comboBox._clientSideFilter) {
      performClientSideFilter(data, comboBox.filter, callback);
    } else {
      // Remove the data if server-side filtering, but keep it for client-side
      // filtering
      delete cache[page];

      // FIXME: It may be that we ought to provide data.length instead of
      // comboBox.size and remove updateSize function.
      callback(data, comboBox.size);
    }
  };

  // Perform filter on client side (here) using the items from specified page
  // and submitting the filtered items to specified callback.
  // The filter used is the one from combobox, not the lastFilter stored since
  // that may not reflect user's input.
  const performClientSideFilter = function (page, filter, callback) {
    let filteredItems = page;

    if (filter) {
      filteredItems = page.filter((item) => comboBox.$connector.filter(item, filter));
    }

    callback(filteredItems, filteredItems.length);
  };

  // Prevent setting the custom value as the 'value'-prop automatically
  comboBox.addEventListener('custom-value-set', (e) => e.preventDefault());

  comboBox.itemClassNameGenerator = function (item) {
    return item.className || '';
  };
}

window.Vaadin.ComboBoxPlaceholder = ComboBoxPlaceholder;
