/*************************************************************
 *
 *  Copyright (c) 2017-2026 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.
 */

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

/*****************************************************************/
const OBJECT = {}.constructor;

/**
 *  Check if an object is an object literal (as opposed to an instance of a class)
 *
 * @param {any} obj A javascript entity
 * @returns {boolean} True if entity is really an object
 */
export function isObject(obj: any): boolean {
  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 = '[-]';

/**
 *  Provides options for the option utlities.
 */
export const OPTIONS = {
  invalidOption: 'warn' as 'fatal' | 'warn',
  /**
   * Function to report messages for invalid options
   *
   * @param {string} message   The message for the invalid parameter.
   * @param {string} _key      The invalid key itself.
   */
  optionError: (message: string, _key: string) => {
    if (OPTIONS.invalidOption === 'fatal') {
      throw new Error(message);
    }
    console.warn('MathJax: ' + message);
  },
};

/**
 * 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.
 *
 * @param {OptionList} def The options list
 * @returns {OptionList} The augmented options list
 */
export function expandable(def: OptionList): OptionList {
  return Object.assign(Object.create(Expandable.prototype), def);
}

/*****************************************************************/
/**
 *  Make sure an option is an Array
 *
 * @param {any} x The object to turn into an array
 * @returns {any[]} The array object containing x
 */
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
 * @returns {(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
 * @returns {OptionList}     The copy of the object
 */
export function copy(def: OptionList): OptionList {
  const props: OptionList = {};
  for (const key of keys(def)) {
    const prop = Object.getOwnPropertyDescriptor(def, key);
    const 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
 * @returns {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();
      }
      OPTIONS.optionError(`Invalid option "${key}" (no default value).`, key);
      continue;
    }
    //
    // Shorthands for the source and destination values
    //
    const sval = src[key];
    let 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
 * @returns {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
 * @returns {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
 * @returns {OptionList}         The option list consisting of only the ones whose keys were given
 */
export function selectOptions(
  options: OptionList,
  ...keys: string[]
): OptionList {
  const subset: OptionList = {};
  for (const key of keys) {
    if (Object.hasOwn(options, 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
 * @returns {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.
 * @returns {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[] {
  const results: OptionList[] = [];
  for (const object of objects) {
    const 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;
}

/*****************************************************************/
/**
 *  Look up a value from object literal, being sure it is an
 *  actual property (not inherited), with a default if not found.
 *
 * @param {string} name         The name of the key to look up.
 * @param {OptionList} lookup   The list of options to check.
 * @param {any} def             The default value if the key isn't found.
 * @returns {any}               The value for the key.
 */
export function lookup(name: string, lookup: OptionList, def: any = null): any {
  return Object.hasOwn(lookup, name) ? lookup[name] : def;
}
