// @flow
import buildCommon from "../buildCommon";
import Style from "../Style";
import defineEnvironment from "../defineEnvironment";
import {parseCD} from "./cd";
import defineFunction from "../defineFunction";
import defineMacro from "../defineMacro";
import mathMLTree from "../mathMLTree";
import ParseError from "../ParseError";
import {assertNodeType, assertSymbolNodeType} from "../parseNode";
import {checkSymbolNodeType} from "../parseNode";
import {Token} from "../Token";
import {calculateSize, makeEm} from "../units";
import utils from "../utils";

import * as html from "../buildHTML";
import * as mml from "../buildMathML";

import type Parser from "../Parser";
import type {ParseNode, AnyParseNode} from "../parseNode";
import type {StyleStr} from "../types";
import type {HtmlBuilder, MathMLBuilder} from "../defineFunction";

// Data stored in the ParseNode associated with the environment.
export type AlignSpec = { type: "separator", separator: string } | {
    type: "align",
    align: string,
    pregap?: number,
    postgap?: number,
};

// Type to indicate column separation in MathML
export type ColSeparationType = "align" | "alignat" | "gather" | "small" | "CD";

// Helper functions
function getHLines(parser: Parser): boolean[] {
    // Return an array. The array length = number of hlines.
    // Each element in the array tells if the line is dashed.
    const hlineInfo = [];
    parser.consumeSpaces();
    let nxt = parser.fetch().text;
    if (nxt === "\\relax") { // \relax is an artifact of the \cr macro below
        parser.consume();
        parser.consumeSpaces();
        nxt = parser.fetch().text;
    }
    while (nxt === "\\hline" || nxt === "\\hdashline") {
        parser.consume();
        hlineInfo.push(nxt === "\\hdashline");
        parser.consumeSpaces();
        nxt = parser.fetch().text;
    }
    return hlineInfo;
}

const validateAmsEnvironmentContext = context => {
    const settings = context.parser.settings;
    if (!settings.displayMode) {
        throw new ParseError(`{${context.envName}} can be used only in` +
            ` display mode.`);
    }
};

// autoTag (an argument to parseArray) can be one of three values:
// * undefined: Regular (not-top-level) array; no tags on each row
// * true: Automatic equation numbering, overridable by \tag
// * false: Tags allowed on each row, but no automatic numbering
// This function *doesn't* work with the "split" environment name.
function getAutoTag(name): ?boolean {
    if (name.indexOf("ed") === -1) {
        return name.indexOf("*") === -1;
    }
    // return undefined;
}

/**
 * Parse the body of the environment, with rows delimited by \\ and
 * columns delimited by &, and create a nested list in row-major order
 * with one group per cell.  If given an optional argument style
 * ("text", "display", etc.), then each cell is cast into that style.
 */
function parseArray(
    parser: Parser,
    {
        hskipBeforeAndAfter,
        addJot,
        cols,
        arraystretch,
        colSeparationType,
        autoTag,
        singleRow,
        emptySingleRow,
        maxNumCols,
        leqno,
    }: {|
        hskipBeforeAndAfter?: boolean,
        addJot?: boolean,
        cols?: AlignSpec[],
        arraystretch?: number,
        colSeparationType?: ColSeparationType,
        autoTag?: ?boolean,
        singleRow?: boolean,
        emptySingleRow?: boolean,
        maxNumCols?: number,
        leqno?: boolean,
    |},
    style: StyleStr,
): ParseNode<"array"> {
    parser.gullet.beginGroup();
    if (!singleRow) {
        // \cr is equivalent to \\ without the optional size argument (see below)
        // TODO: provide helpful error when \cr is used outside array environment
        parser.gullet.macros.set("\\cr", "\\\\\\relax");
    }

    // Get current arraystretch if it's not set by the environment
    if (!arraystretch) {
        const stretch = parser.gullet.expandMacroAsText("\\arraystretch");
        if (stretch == null) {
            // Default \arraystretch from lttab.dtx
            arraystretch = 1;
        } else {
            arraystretch = parseFloat(stretch);
            if (!arraystretch || arraystretch < 0) {
                throw new ParseError(`Invalid \\arraystretch: ${stretch}`);
            }
        }
    }

    // Start group for first cell
    parser.gullet.beginGroup();

    let row = [];
    const body = [row];
    const rowGaps = [];
    const hLinesBeforeRow = [];

    const tags = (autoTag != null ? [] : undefined);

    // amsmath uses \global\@eqnswtrue and \global\@eqnswfalse to represent
    // whether this row should have an equation number.  Simulate this with
    // a \@eqnsw macro set to 1 or 0.
    function beginRow() {
        if (autoTag) {
            parser.gullet.macros.set("\\@eqnsw", "1", true);
        }
    }
    function endRow() {
        if (tags) {
            if (parser.gullet.macros.get("\\df@tag")) {
                tags.push(parser.subparse([new Token("\\df@tag")]));
                parser.gullet.macros.set("\\df@tag", undefined, true);
            } else {
                tags.push(Boolean(autoTag) &&
                    parser.gullet.macros.get("\\@eqnsw") === "1");
            }
        }
    }
    beginRow();

    // Test for \hline at the top of the array.
    hLinesBeforeRow.push(getHLines(parser));

    while (true) {  // eslint-disable-line no-constant-condition
        // Parse each cell in its own group (namespace)
        let cell = parser.parseExpression(false, singleRow ? "\\end" : "\\\\");
        parser.gullet.endGroup();
        parser.gullet.beginGroup();

        cell = {
            type: "ordgroup",
            mode: parser.mode,
            body: cell,
        };
        if (style) {
            cell = {
                type: "styling",
                mode: parser.mode,
                style,
                body: [cell],
            };
        }
        row.push(cell);
        const next = parser.fetch().text;
        if (next === "&") {
            if (maxNumCols && row.length === maxNumCols) {
                if (singleRow || colSeparationType) {
                    // {equation} or {split}
                    throw new ParseError("Too many tab characters: &",
                                        parser.nextToken);
                } else {
                    // {array} environment
                    parser.settings.reportNonstrict("textEnv", "Too few columns " +
                    "specified in the {array} column argument.");
                }
            }
            parser.consume();
        } else if (next === "\\end") {
            endRow();
            // Arrays terminate newlines with `\crcr` which consumes a `\cr` if
            // the last line is empty.  However, AMS environments keep the
            // empty row if it's the only one.
            // NOTE: Currently, `cell` is the last item added into `row`.
            if (row.length === 1 && cell.type === "styling" &&
                cell.body[0].body.length === 0 &&
                (body.length > 1 || !emptySingleRow)) {
                body.pop();
            }
            if (hLinesBeforeRow.length < body.length + 1) {
                hLinesBeforeRow.push([]);
            }
            break;
        } else if (next === "\\\\") {
            parser.consume();
            let size;
            // \def\Let@{\let\\\math@cr}
            // \def\math@cr{...\math@cr@}
            // \def\math@cr@{\new@ifnextchar[\math@cr@@{\math@cr@@[\z@]}}
            // \def\math@cr@@[#1]{...\math@cr@@@...}
            // \def\math@cr@@@{\cr}
            if (parser.gullet.future().text !== " ") {
                size = parser.parseSizeGroup(true);
            }
            rowGaps.push(size ? size.value : null);
            endRow();

            // check for \hline(s) following the row separator
            hLinesBeforeRow.push(getHLines(parser));

            row = [];
            body.push(row);
            beginRow();
        } else {
            throw new ParseError("Expected & or \\\\ or \\cr or \\end",
                                 parser.nextToken);
        }
    }

    // End cell group
    parser.gullet.endGroup();
    // End array group defining \cr
    parser.gullet.endGroup();

    return {
        type: "array",
        mode: parser.mode,
        addJot,
        arraystretch,
        body,
        cols,
        rowGaps,
        hskipBeforeAndAfter,
        hLinesBeforeRow,
        colSeparationType,
        tags,
        leqno,
    };
}


// Decides on a style for cells in an array according to whether the given
// environment name starts with the letter 'd'.
function dCellStyle(envName): StyleStr {
    if (envName.slice(0, 1) === "d") {
        return "display";
    } else {
        return "text";
    }
}

type Outrow = {
    [idx: number]: *,
    height: number,
    depth: number,
    pos: number,
};

const htmlBuilder: HtmlBuilder<"array"> = function(group, options) {
    let r;
    let c;
    const nr = group.body.length;
    const hLinesBeforeRow = group.hLinesBeforeRow;
    let nc = 0;
    let body = new Array(nr);
    const hlines = [];

    const ruleThickness = Math.max(
        // From LaTeX \showthe\arrayrulewidth. Equals 0.04 em.
        (options.fontMetrics().arrayRuleWidth),
        options.minRuleThickness,  // User override.
    );

    // Horizontal spacing
    const pt = 1 / options.fontMetrics().ptPerEm;
    let arraycolsep = 5 * pt; // default value, i.e. \arraycolsep in article.cls
    if (group.colSeparationType && group.colSeparationType === "small") {
        // We're in a {smallmatrix}. Default column space is \thickspace,
        // i.e. 5/18em = 0.2778em, per amsmath.dtx for {smallmatrix}.
        // But that needs adjustment because LaTeX applies \scriptstyle to the
        // entire array, including the colspace, but this function applies
        // \scriptstyle only inside each element.
        const localMultiplier = options.havingStyle(Style.SCRIPT).sizeMultiplier;
        arraycolsep = 0.2778 * (localMultiplier / options.sizeMultiplier);
    }

    // Vertical spacing
    const baselineskip = group.colSeparationType === "CD"
      ? calculateSize({number: 3, unit: "ex"}, options)
      : 12 * pt; // see size10.clo
    // Default \jot from ltmath.dtx
    // TODO(edemaine): allow overriding \jot via \setlength (#687)
    const jot = 3 * pt;
    const arrayskip = group.arraystretch * baselineskip;
    const arstrutHeight = 0.7 * arrayskip; // \strutbox in ltfsstrc.dtx and
    const arstrutDepth = 0.3 * arrayskip;  // \@arstrutbox in lttab.dtx

    let totalHeight = 0;

    // Set a position for \hline(s) at the top of the array, if any.
    function setHLinePos(hlinesInGap: boolean[]) {
        for (let i = 0; i < hlinesInGap.length; ++i) {
            if (i > 0) {
                totalHeight += 0.25;
            }
            hlines.push({pos: totalHeight, isDashed: hlinesInGap[i]});
        }
    }
    setHLinePos(hLinesBeforeRow[0]);

    for (r = 0; r < group.body.length; ++r) {
        const inrow = group.body[r];
        let height = arstrutHeight; // \@array adds an \@arstrut
        let depth = arstrutDepth;   // to each tow (via the template)

        if (nc < inrow.length) {
            nc = inrow.length;
        }

        const outrow: Outrow = (new Array(inrow.length): any);
        for (c = 0; c < inrow.length; ++c) {
            const elt = html.buildGroup(inrow[c], options);
            if (depth < elt.depth) {
                depth = elt.depth;
            }
            if (height < elt.height) {
                height = elt.height;
            }
            outrow[c] = elt;
        }

        const rowGap = group.rowGaps[r];
        let gap = 0;
        if (rowGap) {
            gap = calculateSize(rowGap, options);
            if (gap > 0) { // \@argarraycr
                gap += arstrutDepth;
                if (depth < gap) {
                    depth = gap; // \@xargarraycr
                }
                gap = 0;
            }
        }
        // In AMS multiline environments such as aligned and gathered, rows
        // correspond to lines that have additional \jot added to the
        // \baselineskip via \openup.
        if (group.addJot) {
            depth += jot;
        }

        outrow.height = height;
        outrow.depth = depth;
        totalHeight += height;
        outrow.pos = totalHeight;
        totalHeight += depth + gap; // \@yargarraycr
        body[r] = outrow;

        // Set a position for \hline(s), if any.
        setHLinePos(hLinesBeforeRow[r + 1]);
    }

    const offset = totalHeight / 2 + options.fontMetrics().axisHeight;
    const colDescriptions = group.cols || [];
    const cols = [];
    let colSep;
    let colDescrNum;

    const tagSpans = [];
    if (group.tags && group.tags.some((tag) => tag)) {
        // An environment with manual tags and/or automatic equation numbers.
        // Create node(s), the latter of which trigger CSS counter increment.
        for (r = 0; r < nr; ++r) {
            const rw = body[r];
            const shift = rw.pos - offset;
            const tag = group.tags[r];
            let tagSpan;
            if (tag === true) {  // automatic numbering
                tagSpan = buildCommon.makeSpan(["eqn-num"], [], options);
            } else if (tag === false) {
                // \nonumber/\notag or starred environment
                tagSpan = buildCommon.makeSpan([], [], options);
            } else {  // manual \tag
                tagSpan = buildCommon.makeSpan([],
                    html.buildExpression(tag, options, true), options);
            }
            tagSpan.depth = rw.depth;
            tagSpan.height = rw.height;
            tagSpans.push({type: "elem", elem: tagSpan, shift});
        }
    }

    for (c = 0, colDescrNum = 0;
         // Continue while either there are more columns or more column
         // descriptions, so trailing separators don't get lost.
         c < nc || colDescrNum < colDescriptions.length;
         ++c, ++colDescrNum) {

        let colDescr = colDescriptions[colDescrNum] || {};

        let firstSeparator = true;
        while (colDescr.type === "separator") {
            // If there is more than one separator in a row, add a space
            // between them.
            if (!firstSeparator) {
                colSep = buildCommon.makeSpan(["arraycolsep"], []);
                colSep.style.width =
                    makeEm(options.fontMetrics().doubleRuleSep);
                cols.push(colSep);
            }

            if (colDescr.separator === "|" || colDescr.separator === ":") {
                const lineType = (colDescr.separator === "|") ? "solid" : "dashed";
                const separator = buildCommon.makeSpan(
                    ["vertical-separator"], [], options
                );
                separator.style.height = makeEm(totalHeight);
                separator.style.borderRightWidth = makeEm(ruleThickness);
                separator.style.borderRightStyle = lineType;
                separator.style.margin = `0 ${makeEm(-ruleThickness / 2)}`;
                const shift = totalHeight - offset;
                if (shift) {
                    separator.style.verticalAlign = makeEm(-shift);
                }

                cols.push(separator);
            } else {
                throw new ParseError(
                    "Invalid separator type: " + colDescr.separator);
            }

            colDescrNum++;
            colDescr = colDescriptions[colDescrNum] || {};
            firstSeparator = false;
        }

        if (c >= nc) {
            continue;
        }

        let sepwidth;
        if (c > 0 || group.hskipBeforeAndAfter) {
            sepwidth = utils.deflt(colDescr.pregap, arraycolsep);
            if (sepwidth !== 0) {
                colSep = buildCommon.makeSpan(["arraycolsep"], []);
                colSep.style.width = makeEm(sepwidth);
                cols.push(colSep);
            }
        }

        let col = [];
        for (r = 0; r < nr; ++r) {
            const row = body[r];
            const elem = row[c];
            if (!elem) {
                continue;
            }
            const shift = row.pos - offset;
            elem.depth = row.depth;
            elem.height = row.height;
            col.push({type: "elem", elem: elem, shift: shift});
        }

        col = buildCommon.makeVList({
            positionType: "individualShift",
            children: col,
        }, options);
        col = buildCommon.makeSpan(
            ["col-align-" + (colDescr.align || "c")],
            [col]);
        cols.push(col);

        if (c < nc - 1 || group.hskipBeforeAndAfter) {
            sepwidth = utils.deflt(colDescr.postgap, arraycolsep);
            if (sepwidth !== 0) {
                colSep = buildCommon.makeSpan(["arraycolsep"], []);
                colSep.style.width = makeEm(sepwidth);
                cols.push(colSep);
            }
        }
    }
    body = buildCommon.makeSpan(["mtable"], cols);

    // Add \hline(s), if any.
    if (hlines.length > 0) {
        const line = buildCommon.makeLineSpan("hline", options, ruleThickness);
        const dashes = buildCommon.makeLineSpan("hdashline", options,
            ruleThickness);
        const vListElems = [{type: "elem", elem: body, shift: 0}];
        while (hlines.length > 0) {
            const hline = hlines.pop();
            const lineShift = hline.pos - offset;
            if (hline.isDashed) {
                vListElems.push({type: "elem", elem: dashes, shift: lineShift});
            } else {
                vListElems.push({type: "elem", elem: line, shift: lineShift});
            }
        }
        body = buildCommon.makeVList({
            positionType: "individualShift",
            children: vListElems,
        }, options);
    }

    if (tagSpans.length === 0) {
        return buildCommon.makeSpan(["mord"], [body], options);
    } else {
        let eqnNumCol = buildCommon.makeVList({
            positionType: "individualShift",
            children: tagSpans,
        }, options);
        eqnNumCol = buildCommon.makeSpan(["tag"], [eqnNumCol], options);
        return buildCommon.makeFragment([body, eqnNumCol]);
    }
};

const alignMap = {
    c: "center ",
    l: "left ",
    r: "right ",
};

const mathmlBuilder: MathMLBuilder<"array"> = function(group, options) {
    const tbl = [];
    const glue = new mathMLTree.MathNode("mtd", [], ["mtr-glue"]);
    const tag = new mathMLTree.MathNode("mtd", [], ["mml-eqn-num"]);
    for (let i = 0; i < group.body.length; i++) {
        const rw = group.body[i];
        const row = [];
        for (let j = 0; j < rw.length; j++) {
            row.push(new mathMLTree.MathNode("mtd",
                [mml.buildGroup(rw[j], options)]));
        }
        if (group.tags && group.tags[i]) {
            row.unshift(glue);
            row.push(glue);
            if (group.leqno) {
                row.unshift(tag);
            } else {
                row.push(tag);
            }
        }
        tbl.push(new mathMLTree.MathNode("mtr", row));
    }
    let table = new mathMLTree.MathNode("mtable", tbl);

    // Set column alignment, row spacing, column spacing, and
    // array lines by setting attributes on the table element.

    // Set the row spacing. In MathML, we specify a gap distance.
    // We do not use rowGap[] because MathML automatically increases
    // cell height with the height/depth of the element content.

    // LaTeX \arraystretch multiplies the row baseline-to-baseline distance.
    // We simulate this by adding (arraystretch - 1)em to the gap. This
    // does a reasonable job of adjusting arrays containing 1 em tall content.

    // The 0.16 and 0.09 values are found empirically. They produce an array
    // similar to LaTeX and in which content does not interfere with \hlines.
    const gap = (group.arraystretch === 0.5)
        ? 0.1  // {smallmatrix}, {subarray}
        : 0.16 + group.arraystretch - 1 + (group.addJot ? 0.09 : 0);
    table.setAttribute("rowspacing", makeEm(gap));

    // MathML table lines go only between cells.
    // To place a line on an edge we'll use <menclose>, if necessary.
    let menclose = "";
    let align = "";

    if (group.cols && group.cols.length > 0) {
        // Find column alignment, column spacing, and  vertical lines.
        const cols = group.cols;
        let columnLines = "";
        let prevTypeWasAlign = false;
        let iStart = 0;
        let iEnd = cols.length;

        if (cols[0].type === "separator") {
            menclose += "top ";
            iStart = 1;
        }
        if (cols[cols.length - 1].type === "separator") {
            menclose += "bottom ";
            iEnd -= 1;
        }

        for (let i = iStart; i < iEnd; i++) {
            if (cols[i].type === "align") {
                align += alignMap[cols[i].align];

                if (prevTypeWasAlign) {
                    columnLines += "none ";
                }
                prevTypeWasAlign = true;
            } else if (cols[i].type === "separator") {
                // MathML accepts only single lines between cells.
                // So we read only the first of consecutive separators.
                if (prevTypeWasAlign) {
                    columnLines += cols[i].separator === "|"
                        ? "solid "
                        : "dashed ";
                    prevTypeWasAlign = false;
                }
            }
        }

        table.setAttribute("columnalign", align.trim());

        if (/[sd]/.test(columnLines)) {
            table.setAttribute("columnlines", columnLines.trim());
        }
    }

    // Set column spacing.
    if (group.colSeparationType === "align") {
        const cols = group.cols || [];
        let spacing = "";
        for (let i = 1; i < cols.length; i++) {
            spacing += i % 2 ? "0em " : "1em ";
        }
        table.setAttribute("columnspacing", spacing.trim());
    } else if (group.colSeparationType === "alignat" ||
        group.colSeparationType === "gather") {
        table.setAttribute("columnspacing", "0em");
    } else if (group.colSeparationType === "small") {
        table.setAttribute("columnspacing", "0.2778em");
    } else if (group.colSeparationType === "CD") {
        table.setAttribute("columnspacing", "0.5em");
    } else {
        table.setAttribute("columnspacing", "1em");
    }

    // Address \hline and \hdashline
    let rowLines = "";
    const hlines = group.hLinesBeforeRow;

    menclose += hlines[0].length > 0 ? "left " : "";
    menclose += hlines[hlines.length - 1].length > 0 ? "right " : "";

    for (let i = 1; i < hlines.length - 1; i++) {
        rowLines += (hlines[i].length === 0)
          ? "none "
             // MathML accepts only a single line between rows. Read one element.
          : hlines[i][0] ? "dashed " : "solid ";
    }
    if (/[sd]/.test(rowLines)) {
        table.setAttribute("rowlines", rowLines.trim());
    }

    if (menclose !== "") {
        table = new mathMLTree.MathNode("menclose", [table]);
        table.setAttribute("notation", menclose.trim());
    }

    if (group.arraystretch && group.arraystretch < 1) {
        // A small array. Wrap in scriptstyle so row gap is not too large.
        table = new mathMLTree.MathNode("mstyle", [table]);
        table.setAttribute("scriptlevel", "1");
    }

    return table;
};

// Convenience function for align, align*, aligned, alignat, alignat*, alignedat.
const alignedHandler = function(context, args) {
    if (context.envName.indexOf("ed") === -1) {
        validateAmsEnvironmentContext(context);
    }
    const cols = [];
    const separationType = context.envName.indexOf("at") > -1 ? "alignat" : "align";
    const isSplit = context.envName === "split";
    const res = parseArray(context.parser,
        {
            cols,
            addJot: true,
            autoTag: isSplit ? undefined : getAutoTag(context.envName),
            emptySingleRow: true,
            colSeparationType: separationType,
            maxNumCols: isSplit ? 2 : undefined,
            leqno: context.parser.settings.leqno,
        },
        "display"
    );

    // Determining number of columns.
    // 1. If the first argument is given, we use it as a number of columns,
    //    and makes sure that each row doesn't exceed that number.
    // 2. Otherwise, just count number of columns = maximum number
    //    of cells in each row ("aligned" mode -- isAligned will be true).
    //
    // At the same time, prepend empty group {} at beginning of every second
    // cell in each row (starting with second cell) so that operators become
    // binary.  This behavior is implemented in amsmath's \start@aligned.
    let numMaths;
    let numCols = 0;
    const emptyGroup = {
        type: "ordgroup",
        mode: context.mode,
        body: [],
    };
    if (args[0] && args[0].type === "ordgroup") {
        let arg0 = "";
        for (let i = 0; i < args[0].body.length; i++) {
            const textord = assertNodeType(args[0].body[i], "textord");
            arg0 += textord.text;
        }
        numMaths = Number(arg0);
        numCols = numMaths * 2;
    }
    const isAligned = !numCols;
    res.body.forEach(function(row) {
        for (let i = 1; i < row.length; i += 2) {
            // Modify ordgroup node within styling node
            const styling = assertNodeType(row[i], "styling");
            const ordgroup = assertNodeType(styling.body[0], "ordgroup");
            ordgroup.body.unshift(emptyGroup);
        }
        if (!isAligned) { // Case 1
            const curMaths = row.length / 2;
            if (numMaths < curMaths) {
                throw new ParseError(
                    "Too many math in a row: " +
                    `expected ${numMaths}, but got ${curMaths}`,
                    row[0]);
            }
        } else if (numCols < row.length) { // Case 2
            numCols = row.length;
        }
    });

    // Adjusting alignment.
    // In aligned mode, we add one \qquad between columns;
    // otherwise we add nothing.
    for (let i = 0; i < numCols; ++i) {
        let align = "r";
        let pregap = 0;
        if (i % 2 === 1) {
            align = "l";
        } else if (i > 0 && isAligned) { // "aligned" mode.
            pregap = 1; // add one \quad
        }
        cols[i] = {
            type: "align",
            align: align,
            pregap: pregap,
            postgap: 0,
        };
    }
    res.colSeparationType = isAligned ? "align" : "alignat";
    return res;
};

// Arrays are part of LaTeX, defined in lttab.dtx so its documentation
// is part of the source2e.pdf file of LaTeX2e source documentation.
// {darray} is an {array} environment where cells are set in \displaystyle,
// as defined in nccmath.sty.
defineEnvironment({
    type: "array",
    names: ["array", "darray"],
    props: {
        numArgs: 1,
    },
    handler(context, args) {
        // Since no types are specified above, the two possibilities are
        // - The argument is wrapped in {} or [], in which case Parser's
        //   parseGroup() returns an "ordgroup" wrapping some symbol node.
        // - The argument is a bare symbol node.
        const symNode = checkSymbolNodeType(args[0]);
        const colalign: AnyParseNode[] =
            symNode ? [args[0]] : assertNodeType(args[0], "ordgroup").body;
        const cols = colalign.map(function(nde) {
            const node = assertSymbolNodeType(nde);
            const ca = node.text;
            if ("lcr".indexOf(ca) !== -1) {
                return {
                    type: "align",
                    align: ca,
                };
            } else if (ca === "|") {
                return {
                    type: "separator",
                    separator: "|",
                };
            } else if (ca === ":") {
                return {
                    type: "separator",
                    separator: ":",
                };
            }
            throw new ParseError("Unknown column alignment: " + ca, nde);
        });
        const res = {
            cols,
            hskipBeforeAndAfter: true, // \@preamble in lttab.dtx
            maxNumCols: cols.length,
        };
        return parseArray(context.parser, res, dCellStyle(context.envName));
    },
    htmlBuilder,
    mathmlBuilder,
});

// The matrix environments of amsmath builds on the array environment
// of LaTeX, which is discussed above.
// The mathtools package adds starred versions of the same environments.
// These have an optional argument to choose left|center|right justification.
defineEnvironment({
    type: "array",
    names: [
        "matrix",
        "pmatrix",
        "bmatrix",
        "Bmatrix",
        "vmatrix",
        "Vmatrix",
        "matrix*",
        "pmatrix*",
        "bmatrix*",
        "Bmatrix*",
        "vmatrix*",
        "Vmatrix*",
    ],
    props: {
        numArgs: 0,
    },
    handler(context) {
        const delimiters = {
            "matrix": null,
            "pmatrix": ["(", ")"],
            "bmatrix": ["[", "]"],
            "Bmatrix": ["\\{", "\\}"],
            "vmatrix": ["|", "|"],
            "Vmatrix": ["\\Vert", "\\Vert"],
        }[context.envName.replace("*", "")];
        // \hskip -\arraycolsep in amsmath
        let colAlign = "c";
        const payload = {
            hskipBeforeAndAfter: false,
            cols: [{type: "align", align: colAlign}],
        };
        if (context.envName.charAt(context.envName.length - 1) === "*") {
            // It's one of the mathtools starred functions.
            // Parse the optional alignment argument.
            const parser = context.parser;
            parser.consumeSpaces();
            if (parser.fetch().text === "[") {
                parser.consume();
                parser.consumeSpaces();
                colAlign = parser.fetch().text;
                if ("lcr".indexOf(colAlign) === -1) {
                    throw new ParseError("Expected l or c or r", parser.nextToken);
                }
                parser.consume();
                parser.consumeSpaces();
                parser.expect("]");
                parser.consume();
                payload.cols = [{type: "align", align: colAlign}];
            }
        }
        const res: ParseNode<"array"> =
            parseArray(context.parser, payload, dCellStyle(context.envName));
        // Populate cols with the correct number of column alignment specs.
        const numCols = Math.max(0, ...res.body.map((row) => row.length));
        res.cols = new Array(numCols).fill(
            {type: "align", align: colAlign}
        );
        return delimiters ? {
            type: "leftright",
            mode: context.mode,
            body: [res],
            left: delimiters[0],
            right: delimiters[1],
            rightColor: undefined, // \right uninfluenced by \color in array
        } : res;
    },
    htmlBuilder,
    mathmlBuilder,
});

defineEnvironment({
    type: "array",
    names: ["smallmatrix"],
    props: {
        numArgs: 0,
    },
    handler(context) {
        const payload = {arraystretch: 0.5};
        const res = parseArray(context.parser, payload, "script");
        res.colSeparationType = "small";
        return res;
    },
    htmlBuilder,
    mathmlBuilder,
});

defineEnvironment({
    type: "array",
    names: ["subarray"],
    props: {
        numArgs: 1,
    },
    handler(context, args) {
        // Parsing of {subarray} is similar to {array}
        const symNode = checkSymbolNodeType(args[0]);
        const colalign: AnyParseNode[] =
            symNode ? [args[0]] : assertNodeType(args[0], "ordgroup").body;
        const cols = colalign.map(function(nde) {
            const node = assertSymbolNodeType(nde);
            const ca = node.text;
            // {subarray} only recognizes "l" & "c"
            if ("lc".indexOf(ca) !== -1) {
                return {
                    type: "align",
                    align: ca,
                };
            }
            throw new ParseError("Unknown column alignment: " + ca, nde);
        });
        if (cols.length > 1) {
            throw new ParseError("{subarray} can contain only one column");
        }
        let res = {
            cols,
            hskipBeforeAndAfter: false,
            arraystretch: 0.5,
        };
        res = parseArray(context.parser, res, "script");
        if (res.body.length > 0 &&  res.body[0].length > 1) {
            throw new ParseError("{subarray} can contain only one column");
        }
        return res;
    },
    htmlBuilder,
    mathmlBuilder,
});

// A cases environment (in amsmath.sty) is almost equivalent to
// \def\arraystretch{1.2}%
// \left\{\begin{array}{@{}l@{\quad}l@{}} … \end{array}\right.
// {dcases} is a {cases} environment where cells are set in \displaystyle,
// as defined in mathtools.sty.
// {rcases} is another mathtools environment. It's brace is on the right side.
defineEnvironment({
    type: "array",
    names: [
        "cases",
        "dcases",
        "rcases",
        "drcases",
    ],
    props: {
        numArgs: 0,
    },
    handler(context) {
        const payload = {
            arraystretch: 1.2,
            cols: [{
                type: "align",
                align: "l",
                pregap: 0,
                // TODO(kevinb) get the current style.
                // For now we use the metrics for TEXT style which is what we were
                // doing before.  Before attempting to get the current style we
                // should look at TeX's behavior especially for \over and matrices.
                postgap: 1.0, /* 1em quad */
            }, {
                type: "align",
                align: "l",
                pregap: 0,
                postgap: 0,
            }],
        };
        const res: ParseNode<"array"> =
            parseArray(context.parser, payload, dCellStyle(context.envName));
        return {
            type: "leftright",
            mode: context.mode,
            body: [res],
            left: context.envName.indexOf("r") > -1 ? "." : "\\{",
            right: context.envName.indexOf("r") > -1 ? "\\}" : ".",
            rightColor: undefined,
        };
    },
    htmlBuilder,
    mathmlBuilder,
});

// In the align environment, one uses ampersands, &, to specify number of
// columns in each row, and to locate spacing between each column.
// align gets automatic numbering. align* and aligned do not.
// The alignedat environment can be used in math mode.
// Note that we assume \nomallineskiplimit to be zero,
// so that \strut@ is the same as \strut.
defineEnvironment({
    type: "array",
    names: ["align", "align*", "aligned", "split"],
    props: {
        numArgs: 0,
    },
    handler: alignedHandler,
    htmlBuilder,
    mathmlBuilder,
});

// A gathered environment is like an array environment with one centered
// column, but where rows are considered lines so get \jot line spacing
// and contents are set in \displaystyle.
defineEnvironment({
    type: "array",
    names: ["gathered", "gather", "gather*"],
    props: {
        numArgs: 0,
    },
    handler(context) {
        if (["gather", "gather*"].includes(context.envName)) {
            validateAmsEnvironmentContext(context);
        }
        const res = {
            cols: [{
                type: "align",
                align: "c",
            }],
            addJot: true,
            colSeparationType: "gather",
            autoTag: getAutoTag(context.envName),
            emptySingleRow: true,
            leqno: context.parser.settings.leqno,
        };
        return parseArray(context.parser, res, "display");
    },
    htmlBuilder,
    mathmlBuilder,
});

// alignat environment is like an align environment, but one must explicitly
// specify maximum number of columns in each row, and can adjust spacing between
// each columns.
defineEnvironment({
    type: "array",
    names: ["alignat", "alignat*", "alignedat"],
    props: {
        numArgs: 1,
    },
    handler: alignedHandler,
    htmlBuilder,
    mathmlBuilder,
});

defineEnvironment({
    type: "array",
    names: ["equation", "equation*"],
    props: {
        numArgs: 0,
    },
    handler(context) {
        validateAmsEnvironmentContext(context);
        const res = {
            autoTag: getAutoTag(context.envName),
            emptySingleRow: true,
            singleRow: true,
            maxNumCols: 1,
            leqno: context.parser.settings.leqno,
        };
        return parseArray(context.parser, res, "display");
    },
    htmlBuilder,
    mathmlBuilder,
});

defineEnvironment({
    type: "array",
    names: ["CD"],
    props: {
        numArgs: 0,
    },
    handler(context) {
        validateAmsEnvironmentContext(context);
        return parseCD(context.parser);
    },
    htmlBuilder,
    mathmlBuilder,
});

defineMacro("\\nonumber", "\\gdef\\@eqnsw{0}");
defineMacro("\\notag", "\\nonumber");

// Catch \hline outside array environment
defineFunction({
    type: "text", // Doesn't matter what this is.
    names: ["\\hline", "\\hdashline"],
    props: {
        numArgs: 0,
        allowedInText: true,
        allowedInMath: true,
    },
    handler(context, args) {
        throw new ParseError(
            `${context.funcName} valid only within array environment`);
    },
});
