/*************************************************************
 *
 *  Copyright (c) 2017 The MathJax Consortium
 *
 *  Licensed 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.
 */

/**
 * @fileoverview  Implements functions for handling option lists
 *
 * @author dpvc@mathjax.org (Davide Cervone)
 */


/*****************************************************************/
/* tslint:disable-next-line:jsdoc-require */
const OBJECT = {}.constructor;

/**
 *  Check if an object is an object literal (as opposed to an instance of a class)
 */
function isObject(obj: any) {
  return typeof obj === 'object' && obj !== null &&
    (obj.constructor === OBJECT || obj.constructor === Expandable);
}

/*****************************************************************/
/**
 * Generic list of options
 */
export type OptionList = {[name: string]: any};

/*****************************************************************/
/**
 *  Used to append an array to an array in default options
 *  E.g., an option of the form
 *
 *    {
 *      name: {[APPEND]: [1, 2, 3]}
 *    }
 *
 *  where 'name' is an array in the default options would end up with name having its
 *  original value with 1, 2, and 3 appended.
 */
export const APPEND = '[+]';

/**
 *  Used to remove elements from an array in default options
 *  E.g., an option of the form
 *
 *    {
 *      name: {[REMOVE]: [2]}
 *    }
 *
 *  where 'name' is an array in the default options would end up with name having its
 *  original value but with any entry of 2 removed  So if the original value was [1, 2, 3, 2],
 *  then the final value will be [1, 3] instead.
 */
export const REMOVE = '[-]';

/**
 * A Class to use for options that should not produce warnings if an undefined key is used
 */
export class Expandable {}

/**
 * Produces an instance of Expandable with the given values (to be used in defining options
 * that can use keys that don't have default values).  E.g., default options of the form:
 *
 *  OPTIONS = {
 *     types: expandable({
 *       a: 1,
 *       b: 2
 *     })
 *  }
 *
 *  would allow user options of
 *
 *  {
 *     types: {
 *       c: 3
 *     }
 *  }
 *
 *  without reporting an error.
 */
export function expandable(def: OptionList) {
  return Object.assign(Object.create(Expandable.prototype), def);
}

/*****************************************************************/
/**
 *  Make sure an option is an Array
 */
export function makeArray(x: any): any[] {
  return Array.isArray(x) ? x : [x];
}

/*****************************************************************/
/**
 * Get all keys and symbols from an object
 *
 * @param {Optionlist} def        The object whose keys are to be returned
 * @return {(string | symbol)[]}  The list of keys for the object
 */
export function keys(def: OptionList): (string | symbol)[] {
  if (!def) {
    return [];
  }
  return (Object.keys(def) as (string | symbol)[]).concat(Object.getOwnPropertySymbols(def));
}

/*****************************************************************/
/**
 * Make a deep copy of an object
 *
 * @param {OptionList} def  The object to be copied
 * @return {OptionList}     The copy of the object
 */
export function copy(def: OptionList): OptionList {
  let props: OptionList = {};
  for (const key of keys(def)) {
    let prop = Object.getOwnPropertyDescriptor(def, key);
    let value = prop.value;
    if (Array.isArray(value)) {
      prop.value = insert([], value, false);
    } else if (isObject(value)) {
      prop.value = copy(value);
    }
    if (prop.enumerable) {
      props[key as string] = prop;
    }
  }
  return Object.defineProperties(def.constructor === Expandable ? expandable({}) : {}, props);
}

/*****************************************************************/
/**
 * Insert one object into another (with optional warnings about
 * keys that aren't in the original)
 *
 * @param {OptionList} dst  The option list to merge into
 * @param {OptionList} src  The options to be merged
 * @param {boolean} warn    True if a warning should be issued for a src option that isn't already in dst
 * @return {OptionList}     The modified destination option list (dst)
 */
export function insert(dst: OptionList, src: OptionList, warn: boolean = true): OptionList {
  for (let key of keys(src) as string[]) {
    //
    // Check if the key is valid (i.e., is in the defaults or in an expandable block)
    //
    if (warn && dst[key] === undefined && dst.constructor !== Expandable) {
      if (typeof key === 'symbol') {
        key = (key as symbol).toString();
      }
      throw new Error('Invalid option "' + key + '" (no default value).');
    }
    //
    // Shorthands for the source and destination values
    //
    let sval = src[key], dval = dst[key];
    //
    // If the source is an object literal and the destination exists and is either an
    //   object or a function (so can have properties added to it)...
    //
    if (isObject(sval) && dval !== null &&
        (typeof dval === 'object' || typeof dval === 'function')) {
      const ids = keys(sval);
      //
      // Check for APPEND or REMOVE objects:
      //
      if (
        //
        // If the destination value is an array...
        //
        Array.isArray(dval) &&
          (
            //
            // If there is only one key and it is APPEND or REMOVE and the keys value is an array...
            //
            (ids.length === 1 && (ids[0] === APPEND || ids[0] === REMOVE) && Array.isArray(sval[ids[0]])) ||
              //
              // Or if there are two keys and they are APPEND and REMOVE and both keys' values
              //   are arrays...
              //
              (ids.length === 2 && ids.sort().join(',') === APPEND + ',' + REMOVE &&
               Array.isArray(sval[APPEND]) && Array.isArray(sval[REMOVE]))
          )
      ) {
        //
        // Then remove any values to be removed
        //
        if (sval[REMOVE]) {
          dval = dst[key] = dval.filter(x => sval[REMOVE].indexOf(x) < 0);
        }
        //
        // And append any values to be added (make a copy so as not to modify the original)
        //
        if (sval[APPEND]) {
          dst[key] = [...dval, ...sval[APPEND]];
        }
      } else {
        //
        // Otherwise insert the values of the source object into the destination object
        //
        insert(dval, sval, warn);
      }
    } else if (Array.isArray(sval)) {
      //
      // If the source is an array, replace the destination with an empty array
      //   and copy the source values into it.
      //
      dst[key] = [];
      insert(dst[key], sval, false);
    } else if (isObject(sval)) {
      //
      // If the source is an object literal, set the destination to a copy of it
      //
      dst[key] = copy(sval);
    } else {
      //
      // Otherwise set the destination to the source value
      //
      dst[key] = sval;
    }
  }
  return dst;
}

/*****************************************************************/
/**
 * Merge options without warnings (so we can add new default values into an
 * existing default list)
 *
 * @param {OptionList} options  The option list to be merged into
 * @param {OptionList[]} defs   The option lists to merge into the first one
 * @return {OptionList}         The modified options list
 */
export function defaultOptions(options: OptionList, ...defs: OptionList[]): OptionList {
  defs.forEach(def => insert(options, def, false));
  return options;
}

/*****************************************************************/
/**
 * Merge options with warnings about undefined ones (so we can merge
 * user options into the default list)
 *
 * @param {OptionList} options  The option list to be merged into
 * @param {OptionList[]} defs   The option lists to merge into the first one
 * @return {OptionList}         The modified options list
 */
export function userOptions(options: OptionList, ...defs: OptionList[]): OptionList {
  defs.forEach(def => insert(options, def, true));
  return options;
}

/*****************************************************************/
/**
 * Select a subset of options by key name
 *
 * @param {OptionList} options  The option list from which option values will be taken
 * @param {string[]} keys       The names of the options to extract
 * @return {OptionList}         The option list consisting of only the ones whose keys were given
 */
export function selectOptions(options: OptionList, ...keys: string[]): OptionList {
  let subset: OptionList = {};
  for (const key of keys) {
    if (options.hasOwnProperty(key)) {
      subset[key] = options[key];
    }
  }
  return subset;
}

/*****************************************************************/
/**
 * Select a subset of options by keys from an object
 *
 * @param {OptionList} options  The option list from which the option values will be taken
 * @param {OptionList} object   The option list whose keys will be used to select the options
 * @return {OptionList}         The option list consisting of the option values from the first
 *                               list whose keys are those from the second list.
 */
export function selectOptionsFromKeys(options: OptionList, object: OptionList): OptionList {
  return selectOptions(options, ...Object.keys(object));
}

/*****************************************************************/
/**
 *  Separate options into sets: the ones having the same keys
 *  as the second object, the third object, etc, and the ones that don't.
 *  (Used to separate an option list into the options needed for several
 *   subobjects.)
 *
 * @param {OptionList} options    The option list to be split into parts
 * @param {OptionList[]} objects  The list of option lists whose keys are used to break up
 *                                 the original options into separate pieces.
 * @return {OptionList[]}         The option lists taken from the original based on the
 *                                 keys of the other objects.  The first one in the list
 *                                 consists of the values not appearing in any of the others
 *                                 (i.e., whose keys were not in any of the others).
 */
export function separateOptions(options: OptionList, ...objects: OptionList[]): OptionList[] {
  let results: OptionList[] = [];
  for (const object of objects) {
    let exists: OptionList = {}, missing: OptionList = {};
    for (const key of Object.keys(options || {})) {
      (object[key] === undefined ? missing : exists)[key] = options[key];
    }
    results.push(exists);
    options = missing;
  }
  results.unshift(options);
  return results;
}
