/**
 * Copyright (c) Tiny Technologies, Inc. All rights reserved.
 * Licensed under the LGPL or a commercial license.
 * For LGPL see License.txt in the project root for license information.
 * For commercial licenses see https://www.tiny.cloud/
 */

import Class from 'tinymce/core/api/util/Class';

/*eslint no-nested-ternary:0 */

/**
 * Selector engine, enables you to select controls by using CSS like expressions.
 * We currently only support basic CSS expressions to reduce the size of the core
 * and the ones we support should be enough for most cases.
 *
 * @example
 * Supported expressions:
 *  element
 *  element#name
 *  element.class
 *  element[attr]
 *  element[attr*=value]
 *  element[attr~=value]
 *  element[attr!=value]
 *  element[attr^=value]
 *  element[attr$=value]
 *  element:<state>
 *  element:not(<expression>)
 *  element:first
 *  element:last
 *  element:odd
 *  element:even
 *  element element
 *  element > element
 *
 * @class tinymce.ui.Selector
 */

/**
 * Produces an array with a unique set of objects. It will not compare the values
 * but the references of the objects.
 *
 * @private
 * @method unqiue
 * @param {Array} array Array to make into an array with unique items.
 * @return {Array} Array with unique items.
 */
function unique(array) {
  const uniqueItems = [];
  let i = array.length, item;

  while (i--) {
    item = array[i];

    if (!item.__checked) {
      uniqueItems.push(item);
      item.__checked = 1;
    }
  }

  i = uniqueItems.length;
  while (i--) {
    delete uniqueItems[i].__checked;
  }

  return uniqueItems;
}

const expression = /^([\w\\*]+)?(?:#([\w\-\\]+))?(?:\.([\w\\\.]+))?(?:\[\@?([\w\\]+)([\^\$\*!~]?=)([\w\\]+)\])?(?:\:(.+))?/i;

/*jshint maxlen:255 */
/*eslint max-len:0 */
const chunker = /((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^\[\]]*\]|['"][^'"]*['"]|[^\[\]'"]+)+\]|\\.|[^ >+~,(\[\\]+)+|[>+~])(\s*,\s*)?((?:.|\r|\n)*)/g;
const whiteSpace = /^\s*|\s*$/g;
let Collection;

const Selector = Class.extend({
  /**
   * Constructs a new Selector instance.
   *
   * @constructor
   * @method init
   * @param {String} selector CSS like selector expression.
   */
  init (selector) {
    const match = this.match;

    function compileNameFilter(name) {
      if (name) {
        name = name.toLowerCase();

        return function (item) {
          return name === '*' || item.type === name;
        };
      }
    }

    function compileIdFilter(id) {
      if (id) {
        return function (item) {
          return item._name === id;
        };
      }
    }

    function compileClassesFilter(classes) {
      if (classes) {
        classes = classes.split('.');

        return function (item) {
          let i = classes.length;

          while (i--) {
            if (!item.classes.contains(classes[i])) {
              return false;
            }
          }

          return true;
        };
      }
    }

    function compileAttrFilter(name, cmp, check) {
      if (name) {
        return function (item) {
          const value = item[name] ? item[name]() : '';

          return !cmp ? !!check :
            cmp === '=' ? value === check :
              cmp === '*=' ? value.indexOf(check) >= 0 :
                cmp === '~=' ? (' ' + value + ' ').indexOf(' ' + check + ' ') >= 0 :
                  cmp === '!=' ? value !== check :
                    cmp === '^=' ? value.indexOf(check) === 0 :
                      cmp === '$=' ? value.substr(value.length - check.length) === check :
                        false;
        };
      }
    }

    function compilePsuedoFilter(name) {
      let notSelectors;

      if (name) {
        name = /(?:not\((.+)\))|(.+)/i.exec(name);

        if (!name[1]) {
          name = name[2];

          return function (item, index, length) {
            return name === 'first' ? index === 0 :
              name === 'last' ? index === length - 1 :
                name === 'even' ? index % 2 === 0 :
                  name === 'odd' ? index % 2 === 1 :
                    item[name] ? item[name]() :
                      false;
          };
        }

        // Compile not expression
        notSelectors = parseChunks(name[1], []);

        return function (item) {
          return !match(item, notSelectors);
        };
      }
    }

    function compile(selector, filters, direct) {
      let parts;

      function add(filter) {
        if (filter) {
          filters.push(filter);
        }
      }

      // Parse expression into parts
      parts = expression.exec(selector.replace(whiteSpace, ''));

      add(compileNameFilter(parts[1]));
      add(compileIdFilter(parts[2]));
      add(compileClassesFilter(parts[3]));
      add(compileAttrFilter(parts[4], parts[5], parts[6]));
      add(compilePsuedoFilter(parts[7]));

      // Mark the filter with pseudo for performance
      filters.pseudo = !!parts[7];
      filters.direct = direct;

      return filters;
    }

    // Parser logic based on Sizzle by John Resig
    function parseChunks(selector, selectors) {
      const parts = [];
      let extra, matches, i;

      do {
        chunker.exec('');
        matches = chunker.exec(selector);

        if (matches) {
          selector = matches[3];
          parts.push(matches[1]);

          if (matches[2]) {
            extra = matches[3];
            break;
          }
        }
      } while (matches);

      if (extra) {
        parseChunks(extra, selectors);
      }

      selector = [];
      for (i = 0; i < parts.length; i++) {
        if (parts[i] !== '>') {
          selector.push(compile(parts[i], [], parts[i - 1] === '>'));
        }
      }

      selectors.push(selector);

      return selectors;
    }

    this._selectors = parseChunks(selector, []);
  },

  /**
   * Returns true/false if the selector matches the specified control.
   *
   * @method match
   * @param {tinymce.ui.Control} control Control to match against the selector.
   * @param {Array} selectors Optional array of selectors, mostly used internally.
   * @return {Boolean} true/false state if the control matches or not.
   */
  match (control, selectors) {
    let i, l, si, sl, selector, fi, fl, filters, index, length, siblings, count, item;

    selectors = selectors || this._selectors;
    for (i = 0, l = selectors.length; i < l; i++) {
      selector = selectors[i];
      sl = selector.length;
      item = control;
      count = 0;

      for (si = sl - 1; si >= 0; si--) {
        filters = selector[si];

        while (item) {
          // Find the index and length since a pseudo filter like :first needs it
          if (filters.pseudo) {
            siblings = item.parent().items();
            index = length = siblings.length;
            while (index--) {
              if (siblings[index] === item) {
                break;
              }
            }
          }

          for (fi = 0, fl = filters.length; fi < fl; fi++) {
            if (!filters[fi](item, index, length)) {
              fi = fl + 1;
              break;
            }
          }

          if (fi === fl) {
            count++;
            break;
          } else {
            // If it didn't match the right most expression then
            // break since it's no point looking at the parents
            if (si === sl - 1) {
              break;
            }
          }

          item = item.parent();
        }
      }

      // If we found all selectors then return true otherwise continue looking
      if (count === sl) {
        return true;
      }
    }

    return false;
  },

  /**
   * Returns a tinymce.ui.Collection with matches of the specified selector inside the specified container.
   *
   * @method find
   * @param {tinymce.ui.Control} container Container to look for items in.
   * @return {tinymce.ui.Collection} Collection with matched elements.
   */
  find (container) {
    let matches = [], i, l;
    const selectors = this._selectors;

    function collect(items, selector, index) {
      let i, l, fi, fl, item;
      const filters = selector[index];

      for (i = 0, l = items.length; i < l; i++) {
        item = items[i];

        // Run each filter against the item
        for (fi = 0, fl = filters.length; fi < fl; fi++) {
          if (!filters[fi](item, i, l)) {
            fi = fl + 1;
            break;
          }
        }

        // All filters matched the item
        if (fi === fl) {
          // Matched item is on the last expression like: panel toolbar [button]
          if (index === selector.length - 1) {
            matches.push(item);
          } else {
            // Collect next expression type
            if (item.items) {
              collect(item.items(), selector, index + 1);
            }
          }
        } else if (filters.direct) {
          return;
        }

        // Collect child items
        if (item.items) {
          collect(item.items(), selector, index);
        }
      }
    }

    if (container.items) {
      for (i = 0, l = selectors.length; i < l; i++) {
        collect(container.items(), selectors[i], 0);
      }

      // Unique the matches if needed
      if (l > 1) {
        matches = unique(matches);
      }
    }

    // Fix for circular reference
    if (!Collection) {
      // TODO: Fix me!
      Collection = Selector.Collection;
    }

    return new Collection(matches);
  }
});

export default Selector;