/**
 * This file does the main work of building a domTree structure from a parse
 * tree. The entry point is the `buildHTML` function, which takes a parse tree.
 * Then, the buildExpression, buildGroup, and various groupBuilders functions
 * are called, to produce a final HTML tree.
 */

import ParseError from "./ParseError";
import Style from "./Style";
import {makeGlue, makeSpan, tryCombineChars} from "./buildCommon";
import {Span, Anchor} from "./domTree";
import {makeEm} from "./units";
import {spacings, tightSpacings} from "./spacingData";
import {_htmlGroupBuilders as groupBuilders} from "./defineFunction";
import {DocumentFragment} from "./tree";

import type Options from "./Options";
import type {AnyParseNode} from "./parseNode";
import type {HtmlDomNode, DomSpan} from "./domTree";

// Binary atoms (first class `mbin`) change into ordinary atoms (`mord`)
// depending on their surroundings. See TeXbook pg. 442-446, Rules 5 and 6,
// and the text before Rule 19.
const binLeftCanceller = new Set([
    "leftmost", "mbin", "mopen", "mrel", "mop", "mpunct",
]);
const binRightCanceller = new Set([
    "rightmost", "mrel", "mclose", "mpunct",
]);

const styleMap = {
    "display": Style.DISPLAY,
    "text": Style.TEXT,
    "script": Style.SCRIPT,
    "scriptscript": Style.SCRIPTSCRIPT,
};

type Side = "left" | "right";

const DomEnum = {
    mord: "mord",
    mop: "mop",
    mbin: "mbin",
    mrel: "mrel",
    mopen: "mopen",
    mclose: "mclose",
    mpunct: "mpunct",
    minner: "minner",
};
type DomType = keyof typeof DomEnum;

/**
 * Take a list of nodes, build them in order, and return a list of the built
 * nodes. documentFragments are flattened into their contents, so the
 * returned list contains no fragments. `isRealGroup` is true if `expression`
 * is a real group (no atoms will be added on either side), as opposed to
 * a partial group (e.g. one created by \color). `surrounding` is an array
 * consisting type of nodes that will be added to the left and right.
 */
export const buildExpression = function(
    expression: AnyParseNode[],
    options: Options,
    isRealGroup: boolean | "root",
    surrounding: [DomType | null | undefined, DomType | null | undefined] = [null, null],
): HtmlDomNode[] {
    // Parse expressions into `groups`.
    const groups: HtmlDomNode[] = [];
    for (let i = 0; i < expression.length; i++) {
        const output = buildGroup(expression[i], options);
        if (output instanceof DocumentFragment) {
            const children: ReadonlyArray<HtmlDomNode> = output.children;
            groups.push(...children);
        } else {
            groups.push(output);
        }
    }

    // Combine consecutive domTree.symbolNodes into a single symbolNode.
    tryCombineChars(groups);

    // If `expression` is a partial group, let the parent handle spacings
    // to avoid processing groups multiple times.
    if (!isRealGroup) {
        return groups;
    }

    let glueOptions = options;
    if (expression.length === 1) {
        const node = expression[0];
        if (node.type === "sizing") {
            glueOptions = options.havingSize(node.size);
        } else if (node.type === "styling") {
            glueOptions = options.havingStyle(styleMap[node.style]);
        }
    }

    // Dummy spans for determining spacings between surrounding atoms.
    // If `expression` has no atoms on the left or right, class "leftmost"
    // or "rightmost", respectively, is used to indicate it.
    const dummyPrev = makeSpan([surrounding[0] || "leftmost"], [], options);
    const dummyNext = makeSpan([surrounding[1] || "rightmost"], [], options);

    // TODO: These code assumes that a node's math class is the first element
    // of its `classes` array. A later cleanup should ensure this, for
    // instance by changing the signature of `makeSpan`.

    // Before determining what spaces to insert, perform bin cancellation.
    // Binary operators change to ordinary symbols in some contexts.
    const isRoot = (isRealGroup === "root");
    traverseNonSpaceNodes(groups, (node, prev): void => {
        const prevType = prev.classes[0];
        const type = node.classes[0];
        if (prevType === "mbin" && binRightCanceller.has(type)) {
            prev.classes[0] = "mord";
        } else if (type === "mbin" && binLeftCanceller.has(prevType)) {
            node.classes[0] = "mord";
        }
    }, {node: dummyPrev}, dummyNext, isRoot);

    traverseNonSpaceNodes(groups, (node, prev): HtmlDomNode | undefined => {
        const prevType = getTypeOfDomTree(prev);
        const type = getTypeOfDomTree(node);

        // 'mtight' indicates that the node is script or scriptscript style.
        const space = prevType && type ? (node.hasClass("mtight")
            ? tightSpacings[prevType]?.[type]
            : spacings[prevType]?.[type]) : null;

        if (space) { // Insert glue (spacing) after the `prev`.
            return makeGlue(space, glueOptions);
        }
    }, {node: dummyPrev}, dummyNext, isRoot);

    return groups;
};

// Depth-first traverse non-space `nodes`, calling `callback` with the current and
// previous node as arguments, optionally returning a node to insert after the
// previous node. `prev` is an object with the previous node and `insertAfter`
// function to insert after it. `next` is a node that will be added to the right.
// Used for bin cancellation and inserting spacings.
const traverseNonSpaceNodes = function(
    nodes: HtmlDomNode[],
    callback: (arg0: HtmlDomNode, arg1: HtmlDomNode) =>
        HtmlDomNode | null | void,
    prev: {
        node: HtmlDomNode,
        insertAfter?: (arg0: HtmlDomNode) => void,
    },
    next: HtmlDomNode | null | undefined,
    isRoot: boolean,
) {
    if (next) { // temporarily append the right node, if exists
        nodes.push(next);
    }
    let i = 0;
    for (; i < nodes.length; i++) {
        const node = nodes[i];
        const partialGroup = checkPartialGroup(node);

        if (partialGroup) { // Recursive DFS
            // TODO(ts): partialGroup.children is ReadonlyArray but this
            // function mutates the array (insertAfter splices into it).
            traverseNonSpaceNodes(partialGroup.children as HtmlDomNode[],
                callback, prev, null, isRoot);
            continue;
        }

        // Ignore explicit spaces (e.g., \;, \,) when determining what implicit
        // spacing should go between atoms of different classes
        const nonspace = !node.hasClass("mspace");
        if (nonspace) {
            const result = callback(node, prev.node);
            if (result) {
                if (prev.insertAfter) {
                    prev.insertAfter(result);
                } else { // insert at front
                    nodes.unshift(result);
                    i++;
                }
            }
        }

        if (nonspace) {
            prev.node = node;
        } else if (isRoot && node.hasClass("newline")) {
            prev.node = makeSpan(["leftmost"]); // treat like beginning of line
        }
        prev.insertAfter = (index => n => {
            nodes.splice(index + 1, 0, n);
            i++;
        })(i);
    }

    if (next) {
        nodes.pop();
    }
};

// Check if given node is a partial group, i.e., does not affect spacing around.
const checkPartialGroup = function(
    node: HtmlDomNode,
): DocumentFragment<HtmlDomNode> | Anchor | DomSpan | null | undefined {
    if (node instanceof DocumentFragment || node instanceof Anchor
        || (node instanceof Span && node.hasClass("enclosing"))) {
        return node;
    }
    return null;
};

// Return the outermost node of a domTree.
const getOutermostNode = function(
    node: HtmlDomNode,
    side: Side,
): HtmlDomNode {
    const partialGroup = checkPartialGroup(node);
    if (partialGroup) {
        const children = partialGroup.children;
        if (children.length) {
            if (side === "right") {
                return getOutermostNode(children[children.length - 1], "right");
            } else if (side === "left") {
                return getOutermostNode(children[0], "left");
            }
        }
    }
    return node;
};

// Return math atom class (mclass) of a domTree.
// If `side` is given, it will get the type of the outermost node at given side.
export const getTypeOfDomTree = function(
    node: HtmlDomNode | null | undefined,
    side?: Side | null | undefined,
): DomType | null | undefined {
    if (!node) {
        return null;
    }
    if (side) {
        node = getOutermostNode(node, side);
    }
    // This makes a lot of assumptions as to where the type of atom
    // appears.  We should do a better job of enforcing this.
    const className = node.classes[0] as keyof typeof DomEnum;
    return (DomEnum[className] as DomType | undefined) || null;
};
export const makeNullDelimiter = function(
    options: Options,
    classes: string[],
): DomSpan {
    const moreClasses = ["nulldelimiter"].concat(options.baseSizingClasses());
    return makeSpan(classes.concat(moreClasses));
};

/**
 * buildGroup is the function that takes a group and calls the correct groupType
 * function for it. It also handles the interaction of size and style changes
 * between parents and children.
 */
export const buildGroup = function(
    group: AnyParseNode | null | undefined,
    options: Options,
    baseOptions?: Options,
): HtmlDomNode {
    if (!group) {
        return makeSpan();
    }

    if (groupBuilders[group.type]) {
        // TODO(ts): groupBuilders is Record<string, HtmlBuilder<any>>;
        // a type-safe registry would need a mapped type keyed by NodeType.
        let groupNode: HtmlDomNode = groupBuilders[group.type](group, options);

        // If the size changed between the parent and the current group, account
        // for that size difference.
        if (baseOptions && options.size !== baseOptions.size) {
            groupNode = makeSpan(options.sizingClasses(baseOptions),
                [groupNode], options);

            const multiplier =
                options.sizeMultiplier / baseOptions.sizeMultiplier;

            groupNode.height *= multiplier;
            groupNode.depth *= multiplier;
        }

        return groupNode;
    } else {
        throw new ParseError(
            "Got group of unknown type: '" + group.type + "'");
    }
};

/**
 * Combine an array of HTML DOM nodes (e.g., the output of `buildExpression`)
 * into an unbreakable HTML node of class .base, with proper struts to
 * guarantee correct vertical extent.  `buildHTML` calls this repeatedly to
 * make up the entire expression as a sequence of unbreakable units.
 */
function buildHTMLUnbreakable(children: HtmlDomNode[], options: Options) {
    // Compute height and depth of this chunk.
    const body = makeSpan(["base"], children, options);

    // Add strut, which ensures that the top of the HTML element falls at
    // the height of the expression, and the bottom of the HTML element
    // falls at the depth of the expression.
    const strut = makeSpan(["strut"]);
    strut.style.height = makeEm(body.height + body.depth);
    if (body.depth) {
        strut.style.verticalAlign = makeEm(-body.depth);
    }
    body.children.unshift(strut);

    return body;
}

/**
 * Take an entire parse tree, and build it into an appropriate set of HTML
 * nodes.
 */
export default function buildHTML(tree: AnyParseNode[], options: Options): DomSpan {
    // Strip off outer tag wrapper for processing below.
    let tag = null;
    if (tree.length === 1 && tree[0].type === "tag") {
        tag = tree[0].tag;
        tree = tree[0].body;
    }

    // Build the expression contained in the tree
    const expression = buildExpression(tree, options, "root");

    let eqnNum;
    if (expression.length === 2 && expression[1].hasClass("tag")) {
        // An environment with automatic equation numbers, e.g. {gather}.
        eqnNum = expression.pop();
    }

    const children = [];

    // Create one base node for each chunk between potential line breaks.
    // The TeXBook [p.173] says "A formula will be broken only after a
    // relation symbol like $=$ or $<$ or $\rightarrow$, or after a binary
    // operation symbol like $+$ or $-$ or $\times$, where the relation or
    // binary operation is on the ``outer level'' of the formula (i.e., not
    // enclosed in {...} and not part of an \over construction)."

    let parts = [];
    for (let i = 0; i < expression.length; i++) {
        parts.push(expression[i]);
        if (expression[i].hasClass("mbin") ||
            expression[i].hasClass("mrel") ||
            expression[i].hasClass("allowbreak")) {
            // Put any post-operator glue on same line as operator.
            // Watch for \nobreak along the way, and stop at \newline.
            let nobreak = false;
            while (i < expression.length - 1 &&
                   expression[i + 1].hasClass("mspace") &&
                   !expression[i + 1].hasClass("newline")) {
                i++;
                parts.push(expression[i]);
                if (expression[i].hasClass("nobreak")) {
                    nobreak = true;
                }
            }
            // Don't allow break if \nobreak among the post-operator glue.
            if (!nobreak) {
                children.push(buildHTMLUnbreakable(parts, options));
                parts = [];
            }
        } else if (expression[i].hasClass("newline")) {
            // Write the line except the newline
            parts.pop();
            if (parts.length > 0) {
                children.push(buildHTMLUnbreakable(parts, options));
                parts = [];
            }
            // Put the newline at the top level
            children.push(expression[i]);
        }
    }
    if (parts.length > 0) {
        children.push(buildHTMLUnbreakable(parts, options));
    }

    // Now, if there was a tag, build it too and append it as a final child.
    let tagChild;
    if (tag) {
        tagChild = buildHTMLUnbreakable(
            buildExpression(tag, options, true),
            options,
        );
        tagChild.classes = ["tag"];
        children.push(tagChild);
    } else if (eqnNum) {
        children.push(eqnNum);
    }

    const htmlNode = makeSpan(["katex-html"], children);
    htmlNode.setAttribute("aria-hidden", "true");

    // Adjust the strut of the tag to be the maximum height of all children
    // (the height of the enclosing htmlNode) for proper vertical alignment.
    if (tagChild) {
        const strut = tagChild.children[0];
        strut.style.height = makeEm(htmlNode.height + htmlNode.depth);
        if (htmlNode.depth) {
            strut.style.verticalAlign = makeEm(-htmlNode.depth);
        }
    }

    return htmlNode;
}
