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

/**
 * @fileoverview  Implements the CommonWrapper class
 *
 * @author dpvc@mathjax.org (Davide Cervone)
 */

import {AbstractWrapper, Wrapper, WrapperClass} from '../../core/Tree/Wrapper.js';
import {Node, PropertyList} from '../../core/Tree/Node.js';
import {MmlNode, TextNode, AbstractMmlNode, AttributeList, indentAttributes} from '../../core/MmlTree/MmlNode.js';
import {MmlMo} from '../../core/MmlTree/MmlNodes/mo.js';
import {Property} from '../../core/Tree/Node.js';
import {OptionList} from '../../util/Options.js';
import {unicodeChars} from '../../util/string.js';
import * as LENGTHS from '../../util/lengths.js';
import {Styles} from '../../util/Styles.js';
import {DOMAdaptor} from '../../core/DOMAdaptor.js';
import {CommonOutputJax} from './OutputJax.js';
import {CommonWrapperFactory} from './WrapperFactory.js';
import {BBox, BBoxData} from './BBox.js';
import {FontData, DelimiterData, CharOptions, DIRECTION, NOSTRETCH} from './FontData.js';
import {StyleList} from '../common/CssStyles.js';

/*****************************************************************/

/**
 * Shorthand for a dictionary object (an object of key:value pairs)
 */
export type StringMap = {[key: string]: string};


/**
 * MathML spacing rules
 */
const SMALLSIZE = 2/18;
function MathMLSpace(script: boolean, size: number) {
    return (script ? size < SMALLSIZE ? 0 : SMALLSIZE : size);
}

export type Constructor<T> = new(...args: any[]) => T;

/**
 * Shorthands for wrappers and their constructors
 */
export type AnyWrapper = CommonWrapper<any, any, any, any, any, any>;
export type AnyWrapperClass = CommonWrapperClass<any, any, any, any, any, any>;
export type WrapperConstructor = Constructor<AnyWrapper>;

/*********************************************************/
/**
 *  The CommonWrapper class interface
 *
 * @template J  The OutputJax type
 * @template W  The Wrapper type
 * @template C  The WrapperClass type
 * @template CC The CharOptions type
 * @template FD The FontData type
 */
export interface CommonWrapperClass<
    J extends CommonOutputJax<any, any, any, W, CommonWrapperFactory<J, W, C, CC, DD, FD>, FD, any>,
    W extends CommonWrapper<J, W, C, CC, DD, FD>,
    C extends CommonWrapperClass<J, W, C, CC, DD, FD>,
    CC extends CharOptions,
    DD extends DelimiterData,
    FD extends FontData<CC, any, DD>
> extends WrapperClass<MmlNode, CommonWrapper<J, W, C, CC, DD, FD>> {
    /**
     * @override
     */
    new(factory: CommonWrapperFactory<J, W, C, CC, DD, FD>, node: MmlNode, ...args: any[]): W;
}

/*****************************************************************/
/**
 *  The base CommonWrapper class
 *
 * @template J  The OutputJax type
 * @template W  The Wrapper type
 * @template C  The WrapperClass type
 * @template CC The CharOptions type
 * @template FD The FontData type
 */
export class CommonWrapper<
    J extends CommonOutputJax<any, any, any, W, CommonWrapperFactory<J, W, C, CC, DD, FD>, FD, any>,
    W extends CommonWrapper<J, W, C, CC, DD, FD>,
    C extends CommonWrapperClass<J, W, C, CC, DD, FD>,
    CC extends CharOptions,
    DD extends DelimiterData,
    FD extends FontData<CC, any, DD>
> extends AbstractWrapper<MmlNode, CommonWrapper<J, W, C, CC, DD, FD>> {

    public static kind: string = 'unknown';

    /**
     * Any styles needed for the class
     */
    public static styles: StyleList = {};

    /**
     * Styles that should not be passed on from style attribute
     */
    public static removeStyles: string[] = [
        'fontSize', 'fontFamily', 'fontWeight',
        'fontStyle', 'fontVariant', 'font'
    ];

    /**
     * Non-MathML attributes on MathML elements NOT to be copied to the
     * corresponding DOM elements.  If set to false, then the attribute
     * WILL be copied.  Most of these (like the font attributes) are handled
     * in other ways.
     */
    public static skipAttributes: {[name: string]: boolean} = {
        fontfamily: true, fontsize: true, fontweight: true, fontstyle: true,
        color: true, background: true,
        'class': true, href: true, style: true,
        xmlns: true
    };

    /**
     * The translation of mathvariant to bold or italic styles, or to remove
     * bold or italic from a mathvariant.
     */
    public static BOLDVARIANTS: {[name: string]: StringMap} =  {
        bold: {
            normal: 'bold',
            italic: 'bold-italic',
            fraktur: 'bold-fraktur',
            script: 'bold-script',
            'sans-serif': 'bold-sans-serif',
            'sans-serif-italic': 'sans-serif-bold-italic'
        },
        normal: {
            bold: 'normal',
            'bold-italic': 'italic',
            'bold-fraktur': 'fraktur',
            'bold-script': 'script',
            'bold-sans-serif': 'sans-serif',
            'sans-serif-bold-italic': 'sans-serif-italic'
        }
    };
    public static ITALICVARIANTS: {[name: string]: StringMap} = {
        italic: {
            normal: 'italic',
            bold: 'bold-italic',
            'sans-serif': 'sans-serif-italic',
            'bold-sans-serif': 'sans-serif-bold-italic'
        },
        normal: {
            italic: 'normal',
            'bold-italic': 'bold',
            'sans-serif-italic': 'sans-serif',
            'sans-serif-bold-italic': 'bold-sans-serif'
        }
    };

    /**
     * The factory used to create more wrappers
     */
    protected factory: CommonWrapperFactory<J, W, C, CC, DD, FD>;

    /**
     * The parent and children of this node
     */
    public parent: W = null;
    public childNodes: W[];

    /**
     * Styles that must be handled directly by the wrappers (mostly having to do with fonts)
     */
    protected removedStyles: StringMap = null;

    /**
     * The explicit styles set by the node
     */
    protected styles: Styles = null;

    /**
     * The mathvariant for this node
     */
    public variant: string = '';

    /**
     * The bounding box for this node, and whether it has been computed yet
     */
    public bbox: BBox;
    protected bboxComputed: boolean = false;

    /**
     * Delimiter data for stretching this node (NOSTRETCH means not yet determined)
     */
    public stretch: DD = NOSTRETCH as DD;

    /**
     * Easy access to the font parameters
     */
    public font: FD = null;

    /**
     * Easy access to the output jax for this node
     */
    get jax() {
        return this.factory.jax;
    }

    /**
     * Easy access to the DOMAdaptor object
     */
    get adaptor() {
        return this.factory.jax.adaptor;
    }

    /**
     * Easy access to the metric data for this node
     */
    get metrics() {
        return this.factory.jax.math.metrics;
    }

    /**
     * True if children with percentage widths should be resolved by this container
     */
    get fixesPWidth() {
        return !this.node.notParent && !this.node.isToken;
    }

    /*******************************************************************/

    /**
     * @override
     */
    constructor(factory: CommonWrapperFactory<J, W, C, CC, DD, FD>, node: MmlNode, parent: W = null) {
        super(factory, node);
        this.parent = parent;
        this.font = factory.jax.font;
        this.bbox = BBox.zero();
        this.getStyles();
        this.getVariant();
        this.getScale();
        this.getSpace();
        this.childNodes = node.childNodes.map((child: MmlNode) => {
            const wrapped = this.wrap(child);
            if (wrapped.bbox.pwidth && (node.notParent || node.isKind('math'))) {
                this.bbox.pwidth = BBox.fullWidth;
            }
            return wrapped;
        });
    }

    /**
     * @param {MmlNode} node  The node to the wrapped
     * @param {W} parent  The wrapped parent node
     * @return {W}  The newly wrapped node
     */
    public wrap(node: MmlNode, parent: W = null) {
        const wrapped = this.factory.wrap(node, parent || this);
        if (parent) {
            parent.childNodes.push(wrapped);
        }
        this.jax.nodeMap.set(node, wrapped);
        return wrapped;
    }

    /*******************************************************************/
    /**
     * Return the wrapped node's bounding box, either the cached one, if it exists,
     *   or computed directly if not.
     *
     * @param {boolean} save  Whether to cache the bbox or not (used for stretchy elements)
     * @return {BBox}  The computed bounding box
     */
    public getBBox(save: boolean = true) {
        if (this.bboxComputed) {
            return this.bbox;
        }
        const bbox = (save ? this.bbox : BBox.zero());
        this.computeBBox(bbox);
        this.bboxComputed = save;
        return bbox;
    }

    /**
     * @param {BBox} bbox           The bounding box to modify (either this.bbox, or an empty one)
     * @param {boolean} recompute   True if we are recomputing due to changes in children that have percentage widths
     */
    protected computeBBox(bbox: BBox, recompute: boolean = false) {
        bbox.empty();
        for (const child of this.childNodes) {
            bbox.append(child.getBBox());
        }
        bbox.clean();
        if (this.fixesPWidth && this.setChildPWidths(recompute)) {
            this.computeBBox(bbox, true);
        }
    }

    /**
     * Recursively resolve any percentage widths in the child nodes using the given
     *   container width (or the child width, if none was passed).
     *   Overriden for mtables in order to compute the width.
     *
     * @param {(number|null)=} w   The width of the container (from which percentages are computed)
     * @param {boolean=} clear     True if pwidth marker is to be cleared
     * @return {boolean}           True if a percentage width was found
     */
    public setChildPWidths(recompute: boolean, w: (number | null) = null, clear: boolean = true) {
        if (recompute) {
            return false;
        }
        if (clear) {
           this.bbox.pwidth = '';
        }
        let changed = false;
        for (const child of this.childNodes) {
            const cbox = child.getBBox();
            if (cbox.pwidth && child.setChildPWidths(recompute, w === null ? cbox.w : w, clear)) {
                changed = true;
            }
        }
        return changed;
    }

    /**
     * Mark BBox to be computed again (e.g., when an mo has stretched)
     */
    public invalidateBBox() {
        if (this.bboxComputed) {
            this.bboxComputed = false;
            if (this.parent) {
                this.parent.invalidateBBox();
            }
        }
    }

    /**
     * Copy child skew and italic correction
     *
     * @param {BBox} bbox  The bounding box to modify
     */
    protected copySkewIC(bbox: BBox) {
        const first = this.childNodes[0];
        if (first && first.bbox.sk) {
            bbox.sk = first.bbox.sk;
        }
        const last = this.childNodes[this.childNodes.length - 1];
        if (last && last.bbox.ic) {
            bbox.ic = last.bbox.ic;
            bbox.w += bbox.ic;
        }
    }

    /*******************************************************************/

    /**
     * Add the style attribute, but remove any font-related styles
     *   (since these are handled separately by the variant)
     */
    protected getStyles() {
        const styleString = this.node.attributes.getExplicit('style') as string;
        if (!styleString) return;
        const style = this.styles = new Styles(styleString);
        for (let i = 0, m = CommonWrapper.removeStyles.length; i < m; i++) {
            const id = CommonWrapper.removeStyles[i];
            if (style.get(id)) {
                if (!this.removedStyles) this.removedStyles = {};
                this.removedStyles[id] = style.get(id);
                style.set(id, '');
            }
        }
    }

    /**
     * Get the mathvariant (or construct one, if needed).
     */
    protected getVariant() {
        if (!this.node.isToken) return;
        const attributes = this.node.attributes;
        let variant = attributes.get('mathvariant') as string;
        if (!attributes.getExplicit('mathvariant')) {
            const values = attributes.getList('fontfamily', 'fontweight', 'fontstyle') as StringMap;
            if (this.removedStyles) {
                const style = this.removedStyles;
                if (style.fontFamily) values.family = style.fontFamily;
                if (style.fontWeight) values.weight = style.fontWeight;
                if (style.fontStyle)  values.style  = style.fontStyle;
            }
            if (values.fontfamily) values.family = values.fontfamily;
            if (values.fontweight) values.weight = values.fontweight;
            if (values.fontstyle)  values.style  = values.fontstyle;
            if (values.weight && values.weight.match(/^\d+$/)) {
                values.weight = (parseInt(values.weight) > 600 ? 'bold' : 'normal');
            }
            if (values.family) {
                    variant = this.explicitVariant(values.family, values.weight, values.style);
            } else {
                if (this.node.getProperty('variantForm')) variant = '-tex-variant';
                variant = (CommonWrapper.BOLDVARIANTS[values.weight] || {})[variant] || variant;
                variant = (CommonWrapper.ITALICVARIANTS[values.style] || {})[variant] || variant;
            }
        }
        this.variant = variant;
    }

    /**
     * Set the CSS for a token element having an explicit font (rather than regular mathvariant).
     *
     * @param {string} fontFamily  The font family to use
     * @param {string} fontWeight  The font weight to use
     * @param {string} fontStyle   The font style to use
     */
    protected explicitVariant(fontFamily: string, fontWeight: string, fontStyle: string) {
        let style = this.styles;
        if (!style) style = this.styles = new Styles();
        style.set('fontFamily', fontFamily);
        if (fontWeight) style.set('fontWeight', fontWeight);
        if (fontStyle)  style.set('fontStyle', fontStyle);
        return '-explicitFont';
    }

    /**
     * Determine the scaling factor to use for this wrapped node, and set the styles for it.
     *
     * @return {number}   The scaling factor for this node
     */
    protected getScale() {
        let scale = 1, parent = this.parent;
        let pscale = (parent ? parent.bbox.scale : 1);
        let attributes = this.node.attributes;
        let scriptlevel = Math.min(attributes.get('scriptlevel') as number, 2);
        let fontsize = attributes.get('fontsize');
        let mathsize = (this.node.isToken || this.node.isKind('mstyle') ?
                        attributes.get('mathsize') : attributes.getInherited('mathsize'));
        //
        // If scriptsize is non-zero, set scale based on scriptsizemultiplier
        //
        if (scriptlevel !== 0) {
            scale = Math.pow(attributes.get('scriptsizemultiplier') as number, scriptlevel);
            let scriptminsize = this.length2em(attributes.get('scriptminsize'), .8, 1);
            if (scale < scriptminsize) scale = scriptminsize;
        }
        //
        // If there is style="font-size:...", and not fontsize attribute, use that as fontsize
        //
        if (this.removedStyles && this.removedStyles.fontSize && !fontsize) {
            fontsize = this.removedStyles.fontSize;
        }
        //
        // If there is a fontsize and no mathsize attribute, is that
        //
        if (fontsize && !mathsize) {
            mathsize = fontsize;
        }
        //
        //  Incorporate the mathsize, if any
        //
        if (mathsize !== '1') {
            scale *= this.length2em(mathsize, 1, 1);
        }
        //
        // Record the scaling factors and set the element's CSS
        //
        this.bbox.scale = scale;
        this.bbox.rscale = scale / pscale;
    }

    /**
     * Sets the spacing based on TeX or MathML algorithm
     */
    protected getSpace() {
        const isTop = this.isTopEmbellished();
        const hasSpacing = this.node.hasSpacingAttributes();
        if (this.jax.options.mathmlSpacing || hasSpacing) {
            isTop && this.getMathMLSpacing();
        } else {
            this.getTeXSpacing(isTop, hasSpacing);
        }
    }

    /**
     * Get the spacing using MathML rules based on the core MO
     */
    protected getMathMLSpacing() {
        const node = this.node.coreMO() as MmlMo;
        const attributes = node.attributes;
        const parent = this.jax.nodeMap.get(node.coreParent());
        const isScript = (attributes.get('scriptlevel') > 0);
        this.bbox.L = (attributes.isSet('lspace') ?
                       Math.max(0, this.length2em(attributes.get('lspace'))) :
                       MathMLSpace(isScript, node.lspace));
        this.bbox.R = (attributes.isSet('rspace') ?
                       Math.max(0, this.length2em(attributes.get('rspace'))) :
                       MathMLSpace(isScript, node.rspace));
    }

    /**
     * Get the spacing using the TeX rules
     *
     * @parm {boolean} isTop       True when this is a top-level embellished operator
     * @parm {boolean} hasSpacing  True when there is an explicit or inherited 'form' attribute
     */
    protected getTeXSpacing(isTop: boolean, hasSpacing: boolean) {
        if (!hasSpacing) {
            const space = this.node.texSpacing();
            if (space) {
                this.bbox.L = this.length2em(space);
            }
        }
        if (isTop || hasSpacing) {
            const attributes = this.node.coreMO().attributes;
            if (attributes.isSet('lspace')) {
                this.bbox.L = Math.max(0, this.length2em(attributes.get('lspace')));
            }
            if (attributes.isSet('rspace')) {
                this.bbox.R = Math.max(0, this.length2em(attributes.get('rspace')));
            }
        }
    }

    /**
     * @return {boolean}   True if this is the top-most container of an embellished operator that is
     *                       itself an embellished operator (the maximal embellished operator for its core)
     */
    protected isTopEmbellished() {
        return (this.node.isEmbellished &&
                !(this.node.Parent && this.node.Parent.isEmbellished));
    }

    /*******************************************************************/

    /**
     * @return {CommonWrapper}  The wrapper for this node's core node
     */
    public core() {
        return this.jax.nodeMap.get(this.node.core());
    }

    /**
     * @return {CommonWrapper}  The wrapper for this node's core <mo> node
     */
    public coreMO() {
        return this.jax.nodeMap.get(this.node.coreMO());
    }

    /**
     * @return {string}  For a token node, the combined text content of the node's children
     */
    public getText() {
        let text = '';
        if (this.node.isToken) {
            for (const child of this.node.childNodes) {
                if (child instanceof TextNode) {
                    text += child.getText();
                }
            }
        }
        return text;
    }

    /**
     * @param {DIRECTION} direction  The direction to stretch this node
     * @return {boolean}             Whether the node can stretch in that direction
     */
    public canStretch(direction: DIRECTION): boolean {
        this.stretch = NOSTRETCH as DD;
        if (this.node.isEmbellished) {
            let core = this.core();
            if (core && core.node !== this.node) {
                if (core.canStretch(direction)) {
                    this.stretch = core.stretch;
                }
            }
        }
        return this.stretch.dir !== DIRECTION.None;
    }

    /**
     * @return {[string, number]}  The alignment and indentation shift for the expression
     */
    protected getAlignShift() {
        let {indentalign, indentshift, indentalignfirst, indentshiftfirst} =
            this.node.attributes.getList(...indentAttributes) as StringMap;
        if (indentalignfirst !== 'indentalign') {
            indentalign = indentalignfirst;
        }
        if (indentalign === 'auto') {
            indentalign = this.jax.options.displayAlign;
        }
        if (indentshiftfirst !== 'indentshift') {
            indentshift = indentshiftfirst;
        }
        if (indentshift === 'auto') {
            indentshift = this.jax.options.displayIndent;
            if (indentalign === 'right' && !indentshift.match(/^\s*0[a-z]*\s*$/)) {
                indentshift = ('-' + indentshift.trim()).replace(/^--/, '');
            }
        }
        const shift = this.length2em(indentshift, this.metrics.containerWidth);
        return [indentalign, shift] as [string, number];
    }

    /**
     * @param {number} W       The total width
     * @param {BBox} bbox      The bbox to be aligned
     * @param {string} align   How to align (left, center, right)
     * @return {number}        The x position of the aligned width
     */
    protected getAlignX(W: number, bbox: BBox, align: string) {
        return (align === 'right' ? W - (bbox.w + bbox.R) * bbox.rscale :
                align === 'left' ? bbox.L * bbox.rscale :
                (W - bbox.w * bbox.rscale) / 2);
    }

    /**
     * @param {number} H        The total height
     * @param {number} D        The total depth
     * @param {number} h        The height to be aligned
     * @param {number} d        The depth to be aligned
     * @param {string} align    How to align (top, bottom, middle, axis, baseline)
     * @return {number}         The y position of the aligned baseline
     */
    protected getAlignY(H: number, D: number, h: number, d: number, align: string) {
        return (align === 'top' ? H - h :
                align === 'bottom' ? d - D :
                align === 'middle' ? ((H - h) - (D - d)) / 2 :
                0); // baseline and axis
    }

    /**
     * @param {number} i   The index of the child element whose container is needed
     * @return {number}    The inner width as a container (for percentage widths)
     */
    public getWrapWidth(i: number) {
        return this.childNodes[i].getBBox().w;
    }

    /**
     * @param {number} i   The index of the child element whose container is needed
     * @return {string}    The alignment child element
     */
    public getChildAlign(i: number) {
        return 'left';
    }

    /*******************************************************************/
    /*
     * Easy access to some utility routines
     */

    /**
     * @param {number} m  A number to be shown as a percent
     * @return {string}  The number m as a percent
     */
    protected percent(m: number) {
        return LENGTHS.percent(m);
    }

    /**
     * @param {number} m  A number to be shown in ems
     * @return {string}  The number with units of ems
     */
    protected em(m: number) {
        return LENGTHS.em(m);
    }

    /**
     * @param {number} m   A number of em's to be shown as pixels
     * @param {number} M   The minimum number of pixels to allow
     * @return {string}  The number with units of px
     */
    protected px(m: number, M: number = -LENGTHS.BIGDIMEN) {
        return LENGTHS.px(m, M, this.metrics.em);
    }

    /**
     * @param {Property} length  A dimension (giving number and units) or number to be converted to ems
     * @param {number} size  The default size of the dimension (for percentage values)
     * @param {number} scale  The current scaling factor (to handle absolute units)
     * @return {number}  The dimension converted to ems
     */
    protected length2em(length: Property, size: number = 1, scale: number = null) {
        if (scale === null) {
            scale = this.bbox.scale;
        }
        return LENGTHS.length2em(length as string, size, scale, this.jax.pxPerEm);
    }

    /**
     * @param {string} text  The text to turn into unicode locations
     * @return {number[]}  Array of numbers represeting the string's unicode character positions
     */
    protected unicodeChars(text: string) {
        return unicodeChars(text);
    }

    /**
     * @param {number[]} chars    The array of unicode character numbers to remap
     * @return {number[]}         The converted array
     */
    public remapChars(chars: number[]) {
        return chars;
    }

    /**
     * @param {string} text   The text from which to create a TextNode object
     * @return {TextNode}     The TextNode with the given text
     */
    public mmlText(text: string) {
        return ((this.node as AbstractMmlNode).factory.create('text') as TextNode).setText(text);
    }

    /**
     * @param {string} kind             The kind of MmlNode to create
     * @param {ProperyList} properties  The properties to set initially
     * @param {MmlNode[]} children      The child nodes to add to the created node
     * @return {MmlNode}                The newly created MmlNode
     */
    public mmlNode(kind: string, properties: PropertyList = {}, children: MmlNode[] = []) {
        return (this.node as AbstractMmlNode).factory.create(kind, properties, children);
    }

    /**
     * Create an mo wrapper with the given text,
     *   link it in, and give it the right defaults.
     *
     * @param {string} text     The text for the wrapped element
     * @return {CommonWrapper}  The wrapped MmlMo node
     */
    protected createMo(text: string) {
        const mmlFactory = (this.node as AbstractMmlNode).factory;
        const textNode = (mmlFactory.create('text') as TextNode).setText(text);
        const mml = mmlFactory.create('mo', {stretchy: true}, [textNode]);
        mml.inheritAttributesFrom(this.node);
        const node = this.wrap(mml);
        node.parent = this as any as W;
        return node;
    }

    /**
     * @param {string} variant   The variant in which to look for the character
     * @param {number} n         The number of the character to look up
     * @return {CharData}        The full CharData object, with CharOptions guaranteed to be defined
     */
    protected getVariantChar(variant: string, n: number) {
        const char = this.font.getChar(variant, n) || [0, 0, 0, {unknown: true} as CC];
        if (char.length === 3) {
            (char as any)[3] = {};
        }
        return char as [number, number, number, CC];
    }

}
