/* *
 *
 *  (c) 2009-2019 Øystein Moseng
 *
 *  Accessibility module for Highcharts
 *
 *  License: www.highcharts.com/license
 *
 * */

'use strict';

import H from '../../parts/Globals.js';
import KeyboardNavigationHandler from './KeyboardNavigationHandler.js';
import AccessibilityComponent from './AccessibilityComponent.js';
import KeyboardNavigation from './KeyboardNavigation.js';
import LegendComponent from './components/LegendComponent.js';
import MenuComponent from './components/MenuComponent.js';
import SeriesComponent from './components/SeriesComponent.js';
import ZoomComponent from './components/ZoomComponent.js';
import RangeSelectorComponent from './components/RangeSelectorComponent.js';
import InfoRegionComponent from './components/InfoRegionComponent.js';
import ContainerComponent from './components/ContainerComponent.js';
import defaultOptions from './options.js';
import '../../modules/accessibility/a11y-i18n.js';

var addEvent = H.addEvent,
    doc = H.win.document,
    pick = H.pick,
    merge = H.merge,
    extend = H.extend,
    error = H.error;


// Add default options
merge(true, H.defaultOptions, defaultOptions);

// Expose classes on Highcharts namespace
H.KeyboardNavigationHandler = KeyboardNavigationHandler;
H.AccessibilityComponent = AccessibilityComponent;


/*
 * Add focus border functionality to SVGElements. Draws a new rect on top of
 * element around its bounding box. This is used by multiple components.
 */
H.extend(H.SVGElement.prototype, {

    /**
     * @private
     * @function Highcharts.SVGElement#addFocusBorder
     *
     * @param {number} margin
     *
     * @param {Higcharts.CSSObject} style
     */
    addFocusBorder: function (margin, style) {
        // Allow updating by just adding new border
        if (this.focusBorder) {
            this.removeFocusBorder();
        }
        // Add the border rect
        var bb = this.getBBox(),
            pad = pick(margin, 3);

        bb.x += this.translateX ? this.translateX : 0;
        bb.y += this.translateY ? this.translateY : 0;

        this.focusBorder = this.renderer.rect(
            bb.x - pad,
            bb.y - pad,
            bb.width + 2 * pad,
            bb.height + 2 * pad,
            style && style.borderRadius
        )
            .addClass('highcharts-focus-border')
            .attr({
                zIndex: 99
            })
            .add(this.parentGroup);

        if (!this.renderer.styledMode) {
            this.focusBorder.attr({
                stroke: style && style.stroke,
                'stroke-width': style && style.strokeWidth
            });
        }
    },

    /**
     * @private
     * @function Highcharts.SVGElement#removeFocusBorder
     */
    removeFocusBorder: function () {
        if (this.focusBorder) {
            this.focusBorder.destroy();
            delete this.focusBorder;
        }
    }
});


/**
 * Set chart's focus to an SVGElement. Calls focus() on it, and draws the focus
 * border. This is used by multiple components.
 *
 * @private
 * @function Highcharts.Chart#setFocusToElement
 *
 * @param {Highcharts.SVGElement} svgElement
 *        Element to draw the border around.
 *
 * @param {SVGDOMElement|HTMLDOMElement} [focusElement]
 *        If supplied, it draws the border around svgElement and sets the focus
 *        to focusElement.
 */
H.Chart.prototype.setFocusToElement = function (svgElement, focusElement) {
    var focusBorderOptions = this.options.accessibility
            .keyboardNavigation.focusBorder,
        browserFocusElement = focusElement || svgElement.element;

    // Set browser focus if possible
    if (
        browserFocusElement &&
        browserFocusElement.focus
    ) {
        // If there is no focusin-listener, add one to work around Edge issue
        // where Narrator is not reading out points despite calling focus().
        if (!(
            browserFocusElement.hcEvents &&
            browserFocusElement.hcEvents.focusin
        )) {
            addEvent(browserFocusElement, 'focusin', function () {});
        }

        browserFocusElement.focus();
        // Hide default focus ring
        if (focusBorderOptions.hideBrowserFocusOutline) {
            browserFocusElement.style.outline = 'none';
        }
    }
    if (focusBorderOptions.enabled) {
        // Remove old focus border
        if (this.focusElement) {
            this.focusElement.removeFocusBorder();
        }
        // Draw focus border (since some browsers don't do it automatically)
        svgElement.addFocusBorder(focusBorderOptions.margin, {
            stroke: focusBorderOptions.style.color,
            strokeWidth: focusBorderOptions.style.lineWidth,
            borderRadius: focusBorderOptions.style.borderRadius
        });
        this.focusElement = svgElement;
    }
};


/**
 * Get descriptive label for axis. This is used by multiple components.
 *
 * @private
 * @function Highcharts.Axis#getDescription
 *
 * @return {string}
 */
H.Axis.prototype.getDescription = function () {
    return (
        this.userOptions && this.userOptions.accessibility &&
            this.userOptions.accessibility.description ||
        this.axisTitle && this.axisTitle.textStr ||
        this.options.id ||
        this.categories && 'categories' ||
        this.isDatetimeAxis && 'Time' ||
        'values'
    );
};


/**
 * The Accessibility class
 *
 * @private
 * @requires module:modules/accessibility
 *
 * @class
 * @name Highcharts.Accessibility
 *
 * @param {Highcharts.Chart} chart
 *        Chart object
 */
function Accessibility(chart) {
    this.init(chart);
}

Accessibility.prototype = {

    /**
     * Initialize the accessibility class
     * @private
     * @param {Highcharts.Chart} chart
     *        Chart object
     */
    init: function (chart) {
        var a11yOptions = chart.options.accessibility;
        this.chart = chart;

        // Abort on old browsers
        if (!doc.addEventListener || !chart.renderer.isSVG) {
            chart.renderTo.setAttribute('aria-hidden', true);
            return;
        }

        // Copy over any deprecated options that are used. We could do this on
        // every update, but it is probably not needed.
        this.copyDeprecatedOptions();

        // Add the components
        var components = this.components = {
            container: new ContainerComponent(chart),
            infoRegion: new InfoRegionComponent(chart),
            legend: new LegendComponent(chart),
            chartMenu: new MenuComponent(chart),
            rangeSelector: new RangeSelectorComponent(chart),
            series: new SeriesComponent(chart),
            zoom: new ZoomComponent(chart)
        };
        if (a11yOptions.customComponents) {
            extend(this.components, a11yOptions.customComponents);
        }

        this.keyboardNavigation = new KeyboardNavigation(chart, components);
        this.update();
    },


    /**
     * Update all components.
     */
    update: function () {
        var components = this.components,
            a11yOptions = this.chart.options.accessibility;

        // Update the chart type list as this is used by multiple modules
        this.chart.types = this.getChartTypes();

        // Update markup
        Object.keys(components).forEach(function (componentName) {
            components[componentName].onChartUpdate();
        });

        // Update keyboard navigation
        this.keyboardNavigation.update(
            a11yOptions.keyboardNavigation.order
        );
    },


    /**
     * Destroy all elements.
     */
    destroy: function () {
        var chart = this.chart || {};

        // Destroy components
        var components = this.components;
        Object.keys(components).forEach(function (componentName) {
            components[componentName].destroy();
        });

        // Kill keyboard nav
        this.keyboardNavigation.destroy();

        // Hide container from screen readers if it exists
        if (chart.renderTo) {
            chart.renderTo.setAttribute('aria-hidden', true);
        }

        // Remove focus border if it exists
        if (chart.focusElement) {
            chart.focusElement.removeFocusBorder();
        }
    },


    /**
     * Return a list of the types of series we have in the chart.
     * @private
     */
    getChartTypes: function () {
        var types = {};
        this.chart.series.forEach(function (series) {
            types[series.type] = 1;
        });
        return Object.keys(types);
    },


    /**
     * Copy options that are deprecated over to new options. Logs warnings to
     * console for deprecated options used. The following options are
     * deprecated:
     *
     *  chart.description -> accessibility.description
     *  chart.typeDescription -> accessibility.typeDescription
     *  series.description -> series.accessibility.description
     *  series.exposeElementToA11y -> series.accessibility.exposeAsGroupOnly
     *  series.pointDescriptionFormatter ->
     *      series.accessibility.pointDescriptionFormatter
     *  series.skipKeyboardNavigation ->
     *      series.accessibility.keyboardNavigation.enabled
     *  point.description -> point.accessibility.description
     *  axis.description -> axis.accessibility.description
     *
     * @private
     */
    copyDeprecatedOptions: function () {
        var chart = this.chart,
            // Warn user that a deprecated option was used
            warn = function (oldOption, newOption) {
                error(
                    'Highcharts: Deprecated option ' + oldOption +
                    ' used. Use ' + newOption + ' instead.', false, chart
                );
            },
            // Set a new option on a root prop, where the option is defined as
            // an array of suboptions.
            traverseSetOption = function (val, optionAsArray, root) {
                var opt = root,
                    prop,
                    i = 0;
                for (;i < optionAsArray.length - 1; ++i) {
                    prop = optionAsArray[i];
                    opt = opt[prop] = pick(opt[prop], {});
                }
                opt[optionAsArray[optionAsArray.length - 1]] = val;
            },
            // Map of deprecated series options. New options are defined as
            // arrays of paths under series.options.
            oldToNewSeriesOptions = {
                description: ['accessibility', 'description'],
                exposeElementToA11y: ['accessibility', 'exposeAsGroupOnly'],
                pointDescriptionFormatter: [
                    'accessibility', 'pointDescriptionFormatter'
                ],
                skipKeyboardNavigation: [
                    'accessibility', 'keyboardNavigation', 'enabled'
                ]
            };

        // Deal with chart wide options (description, typeDescription)
        var chartOptions = chart.options.chart || {},
            a11yOptions = chart.options.accessibility || {};
        ['description', 'typeDescription'].forEach(function (prop) {
            if (chartOptions[prop]) {
                a11yOptions[prop] = chartOptions[prop];
                warn('chart.' + prop, 'accessibility.' + prop);
            }
        });

        // Deal with axis description
        chart.axes.forEach(function (axis) {
            var opts = axis.options;
            if (opts && opts.description) {
                opts.accessibility = opts.accessibility || {};
                opts.accessibility.description = opts.description;
                warn('axis.description', 'axis.accessibility.description');
            }
        });

        // Loop through all series and handle options
        if (!chart.series) {
            return;
        }
        chart.series.forEach(function (series) {
            // Handle series wide options
            Object.keys(oldToNewSeriesOptions).forEach(function (oldOption) {
                var optionVal = series.options[oldOption];
                if (optionVal !== undefined) {
                    // Set the new option
                    traverseSetOption(
                        // Note that skipKeyboardNavigation has inverted option
                        // value, since we set enabled rather than disabled
                        oldOption === 'skipKeyboardNavigation' ?
                            !optionVal : optionVal,
                        oldToNewSeriesOptions[oldOption],
                        series.options
                    );
                    warn(
                        'series.' + oldOption, 'series.' +
                        oldToNewSeriesOptions[oldOption].join('.')
                    );
                }
            });

            // Loop through the points and handle point.description
            if (series.points) {
                series.points.forEach(function (point) {
                    if (point.options && point.options.description) {
                        point.options.accessibility =
                            point.options.accessibility || {};
                        point.options.accessibility.description =
                            point.options.description;
                        warn('point.description',
                            'point.accessibility.description');
                    }
                });
            }
        });
    }

};


// Handle updates to the module and send render updates to components
addEvent(H.Chart, 'render', function (e) {
    var a11y = this.accessibility;
    // Update/destroy
    if (this.a11yDirty && this.renderTo) {
        delete this.a11yDirty;
        var accessibilityOptions = this.options.accessibility;
        if (accessibilityOptions && accessibilityOptions.enabled) {
            if (a11y) {
                a11y.update();
            } else {
                this.accessibility = a11y = new Accessibility(this);
            }
        } else if (a11y) {
            // Destroy if after update we have a11y and it is disabled
            if (a11y.destroy) {
                a11y.destroy();
            }
            delete this.accessibility;
        } else {
            // Just hide container
            this.renderTo.setAttribute('aria-hidden', true);
        }
    }
    // Update markup regardless
    if (a11y) {
        Object.keys(a11y.components).forEach(function (componentName) {
            a11y.components[componentName].onChartRender(e);
        });
    }
});

// Update with chart/series/point updates
addEvent(H.Chart, 'update', function (e) {
    // Merge new options
    var newOptions = e.options.accessibility;
    if (newOptions) {
        // Handle custom component updating specifically
        if (newOptions.customComponents) {
            this.options.accessibility.customComponents =
                newOptions.customComponents;
            delete newOptions.customComponents;
        }
        merge(true, this.options.accessibility, newOptions);
        // Recreate from scratch
        if (this.accessibility && this.accessibility.destroy) {
            this.accessibility.destroy();
            delete this.accessibility;
        }
    }

    // Mark dirty for update
    this.a11yDirty = true;
});

// Mark dirty for update
addEvent(H.Point, 'update', function () {
    if (this.series.chart.accessibility) {
        this.series.chart.a11yDirty = true;
    }
});
['addSeries', 'init'].forEach(function (event) {
    addEvent(H.Chart, event, function () {
        this.a11yDirty = true;
    });
});
['update', 'updatedData', 'remove'].forEach(function (event) {
    addEvent(H.Series, event, function () {
        if (this.chart.accessibility) {
            this.chart.a11yDirty = true;
        }
    });
});

// Direct updates (events happen after render)
[
    'afterDrilldown', 'drillupall'
].forEach(function (event) {
    addEvent(H.Chart, event, function () {
        if (this.accessibility) {
            this.accessibility.update();
        }
    });
});

// Destroy with chart
addEvent(H.Chart, 'destroy', function () {
    if (this.accessibility) {
        this.accessibility.destroy();
    }
});
