/*************************************************************
 *
 *  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 the MmlMo node
 *
 * @author dpvc@mathjax.org (Davide Cervone)
 */

import { PropertyList } from '../../Tree/Node.js';
import {
  AbstractMmlTokenNode,
  MmlNode,
  AttributeList,
  TEXCLASS,
} from '../MmlNode.js';
import { MmlMrow } from './mrow.js';
import { MmlMover, MmlMunder, MmlMunderover } from './munderover.js';
import {
  OperatorDef,
  OperatorList,
  OPTABLE,
  OPDEF,
  getRange,
  MMLSPACING,
} from '../OperatorDictionary.js';
import { unicodeChars, unicodeString } from '../../../util/string.js';

/*****************************************************************/
/**
 *  Implements the MmlMo node class (subclass of AbstractMmlTokenNode)
 */

export class MmlMo extends AbstractMmlTokenNode {
  /**
   * @override
   */
  public static defaults: PropertyList = {
    ...AbstractMmlTokenNode.defaults,
    form: 'infix',
    fence: false,
    separator: false,
    lspace: 'thickmathspace',
    rspace: 'thickmathspace',
    stretchy: false,
    symmetric: false,
    maxsize: 'infinity',
    minsize: '0em', // MathML says '1em', but that is larger than some natural sizes
    largeop: false,
    movablelimits: false,
    accent: false,
    linebreak: 'auto',
    lineleading: '100%',
    linebreakstyle: 'before',
    indentalign: 'auto',
    indentshift: '0',
    indenttarget: '',
    indentalignfirst: 'indentalign',
    indentshiftfirst: 'indentshift',
    indentalignlast: 'indentalign',
    indentshiftlast: 'indentshift',
  };

  /**
   * The MathML spacing values for the TeX classes
   */
  public static MMLSPACING = MMLSPACING;

  /**
   * The Operator Dictionary
   */
  public static OPTABLE: { [form: string]: OperatorList } = OPTABLE;

  /**
   * Pattern for matching when the contents is one ore more pseudoscripts
   */
  /* prettier-ignore */
  public static pseudoScripts = new RegExp([
    '^["\'*`',
    '\u00AA',               // FEMININE ORDINAL INDICATOR
    '\u00B0',               // DEGREE SIGN
    '\u00B2-\u00B4',        // SUPERSCRIPT 2 and 3, ACUTE ACCENT
    '\u00B9',               // SUPERSCRIPT ONE
    '\u00BA',               // MASCULINE ORDINAL INDICATOR
    '\u2018-\u201F',        // Various double and single quotation marks (up and down)
    '\u2032-\u2037\u2057',  // Primes and reversed primes (forward and reversed)
    '\u2070\u2071',         // SUPERSCRIPT 0 and i
    '\u2074-\u207F',        // SUPERCRIPT 4 through 9, -, =, (, ), and n
    '\u2080-\u208E',        // SUBSCRIPT 0 through 9, -, =, (, ).
    ']+$'
  ].join(''));

  /**
   * Pattern for when contents is a collection of primes
   */
  protected static primes = new RegExp(
    [
      '^["\'',
      '\u2018-\u201F', // Various double and single quotation marks (up and down)
      ']+$',
    ].join('')
  );

  /**
   * Pattern to use to identify a multiletter operator
   */
  protected static opPattern = /^[a-zA-Z]{2,}$/;

  /**
   * Default map for remapping prime characters
   */
  protected static remapPrimes: { [n: number]: number } = {
    0x0022: 0x2033, // double quotes
    0x0027: 0x2032, // single quote
    0x2018: 0x2035, // open single quote
    0x2019: 0x2032, // close single quote
    0x201a: 0x2032, // low open single quote
    0x201b: 0x2035, // reversed open single quote
    0x201c: 0x2036, // open double quote
    0x201d: 0x2033, // close double quote
    0x201e: 0x2033, // low open double quote
    0x201f: 0x2036, // reversed open double quote
  };

  /**
   * Regular expression matching characters that are marked as math accents
   *  (property mathaccent = true)
   */
  /* prettier-ignore */
  /* eslint-disable no-misleading-character-class */
  public static mathaccents = new RegExp([
    '^[',
    '\u00B4\u0301\u02CA',  // acute
    '\u0060\u0300\u02CB',  // grave
    '\u00A8\u0308',        // ddot
    '\u007E\u0303\u02DC',  // tilde
    '\u00AF\u0304\u02C9',  // bar
    '\u02D8\u0306',        // breve
    '\u02C7\u030C',        // check
    '\u005E\u0302\u02C6',  // hat
    '\u20D0\u20D1',        // harpoons
    '\u20D6\u20D7\u20E1',  // vec (backward, forward, double)
    '\u02D9\u0307',        // dot
    '\u02DA\u030A',        // mathring
    '\u20DB',              // dddot
    '\u20DC',              // ddddot
    ']$'
  ].join(''));

  /**
   * Regular expression matching characters that are marked as math accents
   *   whose widths are to be respected (property mathaccent = false)
   */
  /* prettier-ignore */
  public static mathaccentsWithWidth = new RegExp([
    '^[',
    '\u2190\u2192\u2194',  // arrows
    '\u23DC\u23DD',        // over and under parens
    '\u23DE\u23DF',        // over and under braces
    ']$'
  ].join(''));

  /**
   * The internal TeX class of the node (for use with getter/setter below)
   */
  public _texClass: number = null;

  /**
   * Use a getter to look up the TeX class from the operator table if it hasn't
   * been set yet (but don't save it in case the form changes when it is in its
   * location).
   *
   * @returns {number} The TeX class.
   */
  public get texClass() {
    if (this._texClass === null) {
      return this.getOperatorDef(this.getText())[2];
    }
    return this._texClass;
  }

  /**
   * Use a setter to store the actual value in _texClass;
   */
  public set texClass(value: number) {
    this._texClass = value;
  }

  /**
   * The default MathML spacing on the left
   */
  /* prettier-ignore */
  public lspace = 5/18;

  /**
   * The default MathML spacing on the right
   */
  /* prettier-ignore */
  public rspace = 5/18;

  /**
   * @override
   */
  public get kind() {
    return 'mo';
  }

  /**
   * All <mo> are considered embellished
   *
   * @override
   */
  public get isEmbellished() {
    return true;
  }

  /**
   * @returns {MmlNode}  The node that is the outermost embellished operator
   *                    with this node as its core
   */
  public coreParent(): MmlNode {
    let embellished = null;
    let parent = this as MmlNode;
    const math = this.factory.getNodeClass('math');
    while (
      parent &&
      parent.isEmbellished &&
      parent.coreMO() === this &&
      !(parent instanceof math)
    ) {
      embellished = parent;
      parent = (parent as MmlNode).parent;
    }
    return embellished || this;
  }

  /**
   * @param {MmlNode} parent  The node whose core text is to be obtained
   * @returns {string}         The text of the core MO of the given parent element
   */
  public coreText(parent: MmlNode): string {
    if (!parent) {
      return '';
    }
    if (parent.isEmbellished) {
      return (parent.coreMO() as MmlMo).getText();
    }
    while (
      (((parent.isKind('mrow') ||
        parent.isKind('TeXAtom') ||
        parent.isKind('mstyle') ||
        parent.isKind('mphantom')) &&
        parent.childNodes.length === 1) ||
        parent.isKind('munderover')) &&
      parent.childNodes[0]
    ) {
      parent = parent.childNodes[0];
    }
    return parent.isToken ? (parent as AbstractMmlTokenNode).getText() : '';
  }

  /**
   * @override
   */
  public hasSpacingAttributes() {
    return this.attributes.isSet('lspace') || this.attributes.isSet('rspace');
  }

  /**
   * @returns {boolean}  True is this mo is an accent in an munderover construction
   */
  get isAccent(): boolean {
    let accent = false;
    const node = this.coreParent().parent;
    if (node) {
      const key = node.isKind('mover')
        ? node.childNodes[(node as MmlMover).over].coreMO()
          ? 'accent'
          : ''
        : node.isKind('munder')
          ? node.childNodes[(node as MmlMunder).under].coreMO()
            ? 'accentunder'
            : ''
          : node.isKind('munderover')
            ? this === node.childNodes[(node as MmlMunderover).over].coreMO()
              ? 'accent'
              : this === node.childNodes[(node as MmlMunderover).under].coreMO()
                ? 'accentunder'
                : ''
            : '';
      if (key) {
        const value = node.attributes.getExplicit(key);
        accent = (
          value !== undefined ? accent : this.attributes.get('accent')
        ) as boolean;
      }
    }
    return accent;
  }

  /**
   * Produce the texClass based on the operator dictionary values
   *
   * @override
   */
  public setTeXclass(prev: MmlNode): MmlNode {
    const { form, fence } = this.attributes.getList('form', 'fence') as {
      form: string;
      fence: string;
    };
    if (
      this.getProperty('texClass') === undefined &&
      this.hasSpacingAttributes()
    ) {
      return null;
    }
    if (fence && this.texClass === TEXCLASS.REL) {
      if (form === 'prefix') {
        this.texClass = TEXCLASS.OPEN;
      }
      if (form === 'postfix') {
        this.texClass = TEXCLASS.CLOSE;
      }
    }
    return this.adjustTeXclass(prev);
  }
  /**
   * Follow the TeXBook rules for adjusting the TeX class once its neighbors are known
   *
   * @param {MmlNode} prev  The node appearing before this one in the output
   * @returns {MmlNode}      The last node displayed (this node)
   */
  public adjustTeXclass(prev: MmlNode): MmlNode {
    const texClass = this.texClass;
    let prevClass = this.prevClass;
    if (texClass === TEXCLASS.NONE) {
      return prev;
    }
    if (prev) {
      if (
        prev.getProperty('autoOP') &&
        (texClass === TEXCLASS.BIN || texClass === TEXCLASS.REL)
      ) {
        prevClass = prev.texClass = TEXCLASS.ORD;
      }
      prevClass = this.prevClass = prev.texClass || TEXCLASS.ORD;
      this.prevLevel = this.attributes.getInherited('scriptlevel') as number;
    } else {
      prevClass = this.prevClass = TEXCLASS.NONE;
    }
    if (
      texClass === TEXCLASS.BIN &&
      (prevClass === TEXCLASS.NONE ||
        prevClass === TEXCLASS.BIN ||
        prevClass === TEXCLASS.OP ||
        prevClass === TEXCLASS.REL ||
        prevClass === TEXCLASS.OPEN ||
        prevClass === TEXCLASS.PUNCT)
    ) {
      this.texClass = TEXCLASS.ORD;
    } else if (
      prevClass === TEXCLASS.BIN &&
      (texClass === TEXCLASS.REL ||
        texClass === TEXCLASS.CLOSE ||
        texClass === TEXCLASS.PUNCT)
    ) {
      prev.texClass = this.prevClass = TEXCLASS.ORD;
    } else if (texClass === TEXCLASS.BIN) {
      //
      // Check if node is the last one in its container since the rule
      // above only takes effect if there is a node that follows.
      //
      let child: MmlNode = null;
      let parent = this.parent;
      while (
        parent &&
        parent.parent &&
        parent.isEmbellished &&
        (parent.childNodes.length === 1 ||
          (!parent.isKind('mrow') && parent.core() === child))
      ) {
        child = parent;
        parent = parent.parent;
      }
      child = child || this;
      if (parent.childNodes[parent.childNodes.length - 1] === child) {
        this.texClass = TEXCLASS.ORD;
      }
    }
    return this;
  }

  /**
   * Do the normal inheritance, then look up the attributes from the operator dictionary.
   * If there is no dictionary entry, get the TeX class from the Unicode range list.
   *
   * @override
   */
  public setInheritedAttributes(
    attributes: AttributeList = {},
    display: boolean = false,
    level: number = 0,
    prime: boolean = false
  ) {
    super.setInheritedAttributes(attributes, display, level, prime);
    const mo = this.getText();
    this.checkOperatorTable(mo);
    this.checkPseudoScripts(mo);
    this.checkPrimes(mo);
    this.checkMathAccent(mo);
  }

  /**
   * get the operator definition from the operator table
   *
   * @param {string} mo   The text of the mo element
   * @returns {OperatorDef} The operator definition
   */
  protected getOperatorDef(mo: string): OperatorDef {
    const [form1, form2, form3] = this.handleExplicitForm(this.getForms());
    this.attributes.setInherited('form', form1);
    const CLASS = this.constructor as typeof MmlMo;
    const OPTABLE = CLASS.OPTABLE;
    const def = OPTABLE[form1][mo] || OPTABLE[form2][mo] || OPTABLE[form3][mo];
    if (def) {
      return def;
    }
    this.setProperty('noDictDef', true);
    const limits = this.attributes.get('movablelimits');
    const isOP = !!mo.match(CLASS.opPattern);
    if ((isOP || limits) && this.getProperty('texClass') === undefined) {
      return OPDEF(1, 2, TEXCLASS.OP);
    }
    const range = getRange(mo);
    const [l, r] = CLASS.MMLSPACING[range[2]];
    return OPDEF(l, r, range[2]);
  }

  /**
   * Set the attributes from the operator table
   *
   * @param {string} mo   The text of the mo element
   */
  protected checkOperatorTable(mo: string) {
    const def = this.getOperatorDef(mo);
    if (this.getProperty('texClass') === undefined) {
      this.texClass = def[2];
    }
    for (const name of Object.keys(def[3] || {})) {
      this.attributes.setInherited(name, def[3][name]);
    }
    this.lspace = def[0] / 18;
    this.rspace = def[1] / 18;
  }

  /**
   * @returns {[string, string, string]}  The list of form attribute values in the
   *                                     order they should be tested, based on the
   *                                     position of the element in its parent.
   */
  public getForms(): [string, string, string] {
    let core: MmlNode = null;
    let parent = this.parent;
    let Parent = this.Parent;
    while (Parent && Parent.isEmbellished) {
      core = parent;
      parent = Parent.parent;
      Parent = Parent.Parent;
    }
    core = core || this;
    if (
      parent &&
      parent.isKind('mrow') &&
      (parent as MmlMrow).nonSpaceLength() !== 1
    ) {
      if ((parent as MmlMrow).firstNonSpace() === core) {
        return ['prefix', 'infix', 'postfix'];
      }
      if ((parent as MmlMrow).lastNonSpace() === core) {
        return ['postfix', 'infix', 'prefix'];
      }
    }
    return ['infix', 'prefix', 'postfix'];
  }

  /**
   * @param {string[]} forms     The three forms in the default order they are to be tested
   * @returns {string[]}          The forms in the new order, if there is an explicit form attribute
   */
  protected handleExplicitForm(forms: string[]): string[] {
    if (this.attributes.isSet('form')) {
      const form = this.attributes.get('form') as string;
      forms = [form].concat(forms.filter((name) => name !== form));
    }
    return forms;
  }

  /**
   * Mark the mo as a pseudoscript if it is one.  True means it is,
   *   false means it is a pseudo-script character, but in an msup (so needs a variant form)
   *
   * @param {string} mo   The test of the mo element
   */
  protected checkPseudoScripts(mo: string) {
    const PSEUDOSCRIPTS = (this.constructor as typeof MmlMo).pseudoScripts;
    if (!mo.match(PSEUDOSCRIPTS)) return;
    const parent = this.coreParent().Parent;
    const isPseudo =
      !parent || !(parent.isKind('msubsup') && !parent.isKind('msub'));
    this.setProperty('pseudoscript', isPseudo);
    if (isPseudo) {
      this.attributes.setInherited('lspace', 0);
      this.attributes.setInherited('rspace', 0);
    }
  }

  /**
   * Determine whether the mo consists of primes, and remap them if so.
   *
   * @param {string} mo   The test of the mo element
   */
  protected checkPrimes(mo: string) {
    const PRIMES = (this.constructor as typeof MmlMo).primes;
    if (!mo.match(PRIMES)) return;
    const REMAP = (this.constructor as typeof MmlMo).remapPrimes;
    const primes = unicodeString(unicodeChars(mo).map((c) => REMAP[c]));
    this.setProperty('primes', primes);
  }

  /**
   * Determine whether the mo is a mathaccent character
   *
   * @param {string} mo   The test of the mo element
   */
  protected checkMathAccent(mo: string) {
    const parent = this.Parent;
    if (
      this.getProperty('mathaccent') !== undefined ||
      !parent ||
      !parent.isKind('munderover')
    ) {
      return;
    }
    const [base, under, over] = parent.childNodes;
    if (base.isEmbellished && base.coreMO() === this) return;
    const isUnder = !!(under && under.isEmbellished && under.coreMO() === this);
    const isOver = !!(over && over.isEmbellished && under.coreMO() === this);
    if (!isUnder && !isOver) return;
    if (this.isMathAccent(mo)) {
      this.setProperty('mathaccent', true); // math accent whose width is replaced by 0
    } else if (this.isMathAccentWithWidth(mo)) {
      this.setProperty('mathaccent', false); // math accent whose width is normal
    }
  }

  /**
   * Check a string for being a mathaccent
   *
   * @param {string} mo   The string to check
   * @returns {boolean}    True if the string should be a mathaccent
   */
  public isMathAccent(mo: string = this.getText()): boolean {
    const MATHACCENT = (this.constructor as typeof MmlMo).mathaccents;
    return !!mo.match(MATHACCENT);
  }

  /**
   * Check a string for being a mathaccent with non-zero width
   *
   * @param {string} mo   The string to check
   * @returns {boolean}    True if the string should be a mathaccent
   */
  public isMathAccentWithWidth(mo: string = this.getText()): boolean {
    const MATHACCENT = (this.constructor as typeof MmlMo).mathaccentsWithWidth;
    return !!mo.match(MATHACCENT);
  }
}
