/*************************************************************
 *
 *  Copyright (c) 2018-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 a class that marks complex items for collapsing
 *
 * @author dpvc@mathjax.org (Davide Cervone)
 */

import {
  MmlNode,
  AbstractMmlTokenNode,
  TextNode,
} from '../../core/MmlTree/MmlNode.js';
import { PropertyList } from '../../core/Tree/Node.js';
import { ComplexityVisitor } from './visitor.js';

/*==========================================================================*/

/**
 * Function for checking if a node should be collapsible
 */
export type CollapseFunction = (node: MmlNode, complexity: number) => number;

/**
 * Map of types to collase functions
 */
export type CollapseFunctionMap = Map<string, CollapseFunction>;

/**
 * A list of values indexed by semantic-type, possibly sub-indexed by semantic-role
 *
 * @template T   The type of the indexed item
 */
export type TypeRole<T> = { [type: string]: T | { [role: string]: T } };

/**
 * The class for determining of a subtree can be collapsed
 */
export class Collapse {
  /**
   * A constant to use to indicate no collapsing
   */
  public static NOCOLLAPSE: number = 10000000; // really big complexity

  /**
   * The complexity object containing this one
   */
  public complexity: ComplexityVisitor;

  /**
   * The cutt-off complexity values for when a structure
   *   of the given type should collapse
   */
  public cutoff: TypeRole<number> = {
    identifier: 3,
    number: 3,
    text: 10,
    infixop: 15,
    relseq: 15,
    multirel: 15,
    fenced: 18,
    bigop: 20,
    integral: 20,
    fraction: 12,
    sqrt: 9,
    root: 12,
    vector: 15,
    matrix: 15,
    cases: 15,
    superscript: 9,
    subscript: 9,
    subsup: 9,
    punctuated: {
      endpunct: Collapse.NOCOLLAPSE,
      startpunct: Collapse.NOCOLLAPSE,
      value: 12,
    },
  };

  /**
   *  These are the characters to use for the various collapsed elements
   *  (if an object, then semantic-role is used to get the character
   *  from the object)
   */
  public marker: TypeRole<string> = {
    identifier: 'x',
    number: '#',
    text: '...',
    appl: {
      'limit function': 'lim',
      value: 'f()',
    },
    fraction: '/',
    sqrt: '\u221A',
    root: '\u221A',
    superscript: '\u25FD\u02D9',
    subscript: '\u25FD.',
    subsup: '\u25FD:',
    vector: {
      binomial: '(:)',
      determinant: '|:|',
      value: '\u27E8:\u27E9',
    },
    matrix: {
      squarematrix: '[::]',
      rowvector: '\u27E8\u22EF\u27E9',
      columnvector: '\u27E8\u22EE\u27E9',
      determinant: '|::|',
      value: '(::)',
    },
    cases: '{:',
    infixop: {
      addition: '+',
      subtraction: '\u2212',
      multiplication: '\u22C5',
      implicit: '\u22C5',
      value: '+',
    },
    punctuated: {
      text: '...',
      value: ',',
    },
  };

  /**
   * The type-to-function mapping for semantic types
   *
   * @param {MmlNode} node The current node
   * @param {number} complexity The current complexity
   * @returns {number} The newly computed complexity
   */
  public collapse: CollapseFunctionMap = new Map([
    //
    //  For fenced elements, if the contents are collapsed,
    //    collapse the fence instead.
    //
    [
      'fenced',
      (node, complexity) => {
        complexity = this.uncollapseChild(complexity, node, 1);
        if (
          complexity > (this.cutoff.fenced as number) &&
          node.attributes.get('data-semantic-role') === 'leftright'
        ) {
          complexity = this.recordCollapse(
            node,
            complexity,
            this.getText(node.childNodes[0]) +
              this.getText(node.childNodes[node.childNodes.length - 1])
          );
        }
        return complexity;
      },
    ],

    //
    //  Collapse function applications if the argument is collapsed
    //    (FIXME: Handle role="limit function" a bit better?)
    //
    [
      'appl',
      (node, complexity) => {
        if (this.canUncollapse(node, 2, 2)) {
          complexity = this.complexity.visitNode(node, false);
          const marker = this.marker.appl as { [name: string]: string };
          const text =
            marker[node.attributes.get('data-semantic-role') as string] ||
            marker.value;
          complexity = this.recordCollapse(node, complexity, text);
        }
        return complexity;
      },
    ],

    //
    //  For sqrt elements, if the contents are collapsed,
    //    collapse the sqrt instead.
    //
    [
      'sqrt',
      (node, complexity) => {
        complexity = this.uncollapseChild(complexity, node, 0);
        if (complexity > (this.cutoff.sqrt as number)) {
          node.setProperty('collapse-variant', true);
          complexity = this.recordCollapse(
            node,
            complexity,
            this.marker.sqrt as string
          );
        }
        return complexity;
      },
    ],
    [
      'root',
      (node, complexity) => {
        complexity = this.uncollapseChild(complexity, node, 0, 2);
        if (complexity > (this.cutoff.sqrt as number)) {
          node.setProperty('collapse-variant', true);
          complexity = this.recordCollapse(
            node,
            complexity,
            this.marker.sqrt as string
          );
        }
        return complexity;
      },
    ],

    //
    //  For enclose, include enclosure in collapse
    //
    [
      'enclose',
      (node, complexity) => {
        if (this.splitAttribute(node, 'children').length === 1) {
          const child = this.canUncollapse(node, 1);
          if (child) {
            const marker = child.getProperty('collapse-marker') as string;
            this.unrecordCollapse(child);
            complexity = this.recordCollapse(
              node,
              this.complexity.visitNode(node, false),
              marker
            );
          }
        }
        return complexity;
      },
    ],

    //
    //  For bigops, get the character to use from the largeop at its core.
    //
    [
      'bigop',
      (node, complexity) => {
        if (complexity > (this.cutoff.bigop as number) || !node.isKind('mo')) {
          const id = this.splitAttribute(node, 'content').pop();
          const op = this.findChildText(node, id);
          complexity = this.recordCollapse(node, complexity, op);
        }
        return complexity;
      },
    ],
    [
      'integral',
      (node, complexity) => {
        if (
          complexity > (this.cutoff.integral as number) ||
          !node.isKind('mo')
        ) {
          const id = this.splitAttribute(node, 'content').pop();
          const op = this.findChildText(node, id);
          complexity = this.recordCollapse(node, complexity, op);
        }
        return complexity;
      },
    ],

    //
    //  For relseq and multirel, use proper symbol
    //
    [
      'relseq',
      (node, complexity) => {
        if (complexity > (this.cutoff.relseq as number)) {
          const id = this.splitAttribute(node, 'content')[0];
          const text = this.findChildText(node, id);
          complexity = this.recordCollapse(node, complexity, text);
        }
        return complexity;
      },
    ],
    [
      'multirel',
      (node, complexity) => {
        if (complexity > (this.cutoff.relseq as number)) {
          const id = this.splitAttribute(node, 'content')[0];
          const text = this.findChildText(node, id) + '\u22EF';
          complexity = this.recordCollapse(node, complexity, text);
        }
        return complexity;
      },
    ],

    //
    //  Include super- and subscripts into a collapsed base
    //
    [
      'superscript',
      (node, complexity) => {
        complexity = this.uncollapseChild(complexity, node, 0, 2);
        if (complexity > (this.cutoff.superscript as number)) {
          complexity = this.recordCollapse(
            node,
            complexity,
            this.marker.superscript as string
          );
        }
        return complexity;
      },
    ],
    [
      'subscript',
      (node, complexity) => {
        complexity = this.uncollapseChild(complexity, node, 0, 2);
        if (complexity > (this.cutoff.subscript as number)) {
          complexity = this.recordCollapse(
            node,
            complexity,
            this.marker.subscript as string
          );
        }
        return complexity;
      },
    ],
    [
      'subsup',
      (node, complexity) => {
        complexity = this.uncollapseChild(complexity, node, 0, 3);
        if (complexity > (this.cutoff.subsup as number)) {
          complexity = this.recordCollapse(
            node,
            complexity,
            this.marker.subsup as string
          );
        }
        return complexity;
      },
    ],
  ] as [string, CollapseFunction][]);

  /**
   * The highest id number used for mactions so far
   */
  private idCount = 0;

  /**
   * @param {ComplexityVisitor} visitor  The visitor for computing complexities
   */
  constructor(visitor: ComplexityVisitor) {
    this.complexity = visitor;
  }

  /**
   * Check if a node should be collapsible and insert the
   *  maction node to handle that.  Return the updated
   *  complexity.
   *
   * @param {MmlNode} node        The node to check
   * @param {number} complexity   The current complexity of the node
   * @returns {number}             The revised complexity
   */
  public check(node: MmlNode, complexity: number): number {
    const type = node.attributes.get('data-semantic-type') as string;
    if (this.collapse.has(type)) {
      return this.collapse.get(type).call(this, node, complexity);
    }
    if (Object.hasOwn(this.cutoff, type)) {
      return this.defaultCheck(node, complexity, type);
    }
    return complexity;
  }

  /**
   * Check if the complexity exceeds the cutoff value for the type
   *
   * @param {MmlNode} node        The node to check
   * @param {number} complexity   The current complexity of the node
   * @param {string} type         The semantic type of the node
   * @returns {number}             The revised complexity
   */
  protected defaultCheck(
    node: MmlNode,
    complexity: number,
    type: string
  ): number {
    const role = node.attributes.get('data-semantic-role') as string;
    const check = this.cutoff[type];
    const cutoff =
      typeof check === 'number' ? check : check[role] || check.value;
    if (complexity > cutoff) {
      const marker = this.marker[type] || '??';
      const text =
        typeof marker === 'string' ? marker : marker[role] || marker.value;
      complexity = this.recordCollapse(node, complexity, text);
    }
    return complexity;
  }

  /**
   * @param {MmlNode} node       The node to check
   * @param {number} complexity  The current complexity of the node
   * @param {string} text        The text to use for the collapsed node
   * @returns {number}            The revised complexity for the collapsed node
   */
  protected recordCollapse(
    node: MmlNode,
    complexity: number,
    text: string
  ): number {
    text = '\u25C2' + text + '\u25B8';
    node.setProperty('collapse-marker', text);
    node.setProperty('collapse-complexity', complexity);
    return text.length * this.complexity.complexity.text;
  }

  /**
   * Remove collapse markers (to move them to a parent node)
   *
   * @param {MmlNode} node   The node to uncollapse
   */
  protected unrecordCollapse(node: MmlNode) {
    const complexity = node.getProperty('collapse-complexity');
    if (complexity != null) {
      node.attributes.set('data-semantic-complexity', complexity);
      node.removeProperty('collapse-complexity');
      node.removeProperty('collapse-marker');
    }
  }

  /**
   * @param {MmlNode} node    The node to check if its child is collapsible
   * @param {number} n        The position of the child node to check
   * @param {number=} m       The number of children node must have
   * @returns {MmlNode|null}   The child node that was collapsed (or null)
   */
  protected canUncollapse(
    node: MmlNode,
    n: number,
    m: number = 1
  ): MmlNode | null {
    if (this.splitAttribute(node, 'children').length === m) {
      const mml =
        node.childNodes.length === 1 && node.childNodes[0].isInferred
          ? node.childNodes[0]
          : node;
      if (mml && mml.childNodes[n]) {
        const child = mml.childNodes[n];
        if (child.getProperty('collapse-marker')) {
          return child;
        }
      }
    }
    return null;
  }

  /**
   * @param {number} complexity   The current complexity
   * @param {MmlNode} node        The node to check
   * @param {number} n            The position of the child node to check
   * @param {number=} m           The number of children the node must have
   * @returns {number}             The updated complexity
   */
  protected uncollapseChild(
    complexity: number,
    node: MmlNode,
    n: number,
    m: number = 1
  ): number {
    const child = this.canUncollapse(node, n, m);
    if (child) {
      this.unrecordCollapse(child);
      if (child.parent !== node) {
        child.parent.attributes.set('data-semantic-complexity', undefined);
      }
      complexity = this.complexity.visitNode(node, false) as number;
    }
    return complexity;
  }

  /**
   * @param {MmlNode} node   The node whose attribute is to be split
   * @param {string} id      The name of the data-semantic attribute to split
   * @returns {string[]}      Array of ids in the attribute split at commas
   */
  protected splitAttribute(node: MmlNode, id: string): string[] {
    return ((node.attributes.get('data-semantic-' + id) as string) || '').split(
      /,/
    );
  }

  /**
   * @param {MmlNode} node   The node whose text content is needed
   * @returns {string}       The text of the node (and its children), combined
   */
  protected getText(node: MmlNode): string {
    if (node.isToken) return (node as AbstractMmlTokenNode).getText();
    return node.childNodes.map((n: MmlNode) => this.getText(n)).join('');
  }

  /**
   * @param {MmlNode} node   The node whose child text is needed
   * @param {string} id      The (semantic) id of the child needed
   * @returns {string}        The text of the specified child node
   */
  protected findChildText(node: MmlNode, id: string): string {
    const child = this.findChild(node, id);
    return this.getText(child.coreMO() || child);
  }

  /**
   * @param {MmlNode} node    The node whose child is to be located
   * @param {string} id       The (semantic) id of the child to be found
   * @returns {MmlNode|null}   The child node (or null if not found)
   */
  protected findChild(node: MmlNode, id: string): MmlNode | null {
    if (!node || node.attributes.get('data-semantic-id') === id) return node;
    if (!node.isToken) {
      for (const mml of node.childNodes) {
        const child = this.findChild(mml, id);
        if (child) return child;
      }
    }
    return null;
  }

  /**
   * Add maction nodes to the nodes in the tree that can collapse
   *
   * @param {MmlNode} node     The root of the tree to check
   * @param {number|null} id   The initial id to use
   * @returns {number}         The initial id used
   */
  public makeCollapse(node: MmlNode, id: number | null): number {
    let oldCount = null;
    if (id === null) {
      id = this.idCount;
    } else {
      oldCount = this.idCount;
      this.idCount = id;
    }
    const nodes: MmlNode[] = [];
    node.walkTree((child: MmlNode) => {
      if (child.getProperty('collapse-marker')) {
        nodes.push(child);
      }
    });
    this.makeActions(nodes);
    if (oldCount !== null) {
      this.idCount = oldCount;
    }
    return id;
  }

  /**
   * @param {MmlNode[]} nodes   The list of nodes to replace by maction nodes
   */
  public makeActions(nodes: MmlNode[]) {
    for (const node of nodes) {
      this.makeAction(node);
    }
  }

  /**
   * @returns {string}   A unique id string.
   */
  private makeId(): string {
    return 'mjx-collapse-' + this.idCount++;
  }

  /**
   * @param {MmlNode} node   The node to make collapsible by replacing with an maction
   */
  public makeAction(node: MmlNode) {
    if (node.isKind('math')) {
      node = this.addMrow(node);
    }
    const factory = this.complexity.factory;
    const marker = node.getProperty('collapse-marker') as string;
    const parent = node.parent;
    const variant = { 'data-mjx-collapsed': true } as PropertyList;
    if (node.getProperty('collapse-variant')) {
      variant.mathvariant = '-tex-variant';
    }
    const maction = factory.create(
      'maction',
      {
        actiontype: 'toggle',
        selection: 2,
        'data-collapsible': true,
        id: this.makeId(),
        'data-semantic-complexity': node.attributes.get(
          'data-semantic-complexity'
        ),
      },
      [
        factory.create('mtext', variant, [
          (factory.create('text') as TextNode).setText(marker),
        ]),
      ]
    );
    maction.inheritAttributesFrom(node);
    node.attributes.set(
      'data-semantic-complexity',
      node.getProperty('collapse-complexity')
    );
    node.removeProperty('collapse-marker');
    node.removeProperty('collapse-complexity');
    parent.replaceChild(maction, node);
    maction.appendChild(node);
  }

  /**
   * If the <math> node is to be collapsible, add an mrow to it instead so that we can wrap it
   *  in an maction (can't put one around the <math> node).
   *
   * @param {MmlNode} node  The math node to create an mrow for
   * @returns {MmlNode}      The newly created mrow
   */
  public addMrow(node: MmlNode): MmlNode {
    const mrow = this.complexity.factory.create(
      'mrow',
      null,
      node.childNodes[0].childNodes
    );
    node.childNodes[0].setChildren([mrow]);

    const attributes = node.attributes.getAllAttributes();
    for (const name of Object.keys(attributes)) {
      if (
        name.substring(0, 14) === 'data-semantic-' ||
        name.substring(0, 12) === 'data-speech-' ||
        name.substring(0, 5) === 'aria-' ||
        name === 'role'
      ) {
        mrow.attributes.set(name, attributes[name]);
        delete attributes[name];
      }
    }

    mrow.setProperty('collapse-marker', node.getProperty('collapse-marker'));
    mrow.setProperty(
      'collapse-complexity',
      node.getProperty('collapse-complexity')
    );
    node.removeProperty('collapse-marker');
    node.removeProperty('collapse-complexity');
    return mrow;
  }
}
