/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.  See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership.  The ASF licenses this file
* to you 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.
*/


/**
 * Caution: If the mechanism should be changed some day, these cases
 * should be considered:
 *
 * (1) In `merge option` mode, if using the same option to call `setOption`
 * many times, the result should be the same (try our best to ensure that).
 * (2) In `merge option` mode, if a component has no id/name specified, it
 * will be merged by index, and the result sequence of the components is
 * consistent to the original sequence.
 * (3) In `replaceMerge` mode, keep the result sequence of the components is
 * consistent to the original sequence, even though there might result in "hole".
 * (4) `reset` feature (in toolbox). Find detailed info in comments about
 * `mergeOption` in module:echarts/model/OptionManager.
 */

import {
    each, filter, isArray, isObject, isString,
    createHashMap, assert, clone, merge, extend, mixin, HashMap, isFunction
} from 'zrender/src/core/util';
import * as modelUtil from '../util/model';
import Model from './Model';
import ComponentModel, {ComponentModelConstructor} from './Component';
import globalDefault from './globalDefault';
import {resetSourceDefaulter} from '../data/helper/sourceHelper';
import SeriesModel from './Series';
import {
    Payload,
    OptionPreprocessor,
    ECBasicOption,
    ECUnitOption,
    ThemeOption,
    ComponentOption,
    ComponentMainType,
    ComponentSubType,
    OptionId,
    OptionName,
    AriaOptionMixin
} from '../util/types';
import OptionManager from './OptionManager';
import Scheduler from '../core/Scheduler';
import { concatInternalOptions } from './internalComponentCreator';
import { LocaleOption } from '../core/locale';
import {PaletteMixin} from './mixin/palette';
import { error, warn } from '../util/log';

export interface GlobalModelSetOptionOpts {
    replaceMerge: ComponentMainType | ComponentMainType[];
}
export interface InnerSetOptionOpts {
    replaceMergeMainTypeMap: HashMap<boolean, string>;
}

// -----------------------
// Internal method names:
// -----------------------
let reCreateSeriesIndices: (ecModel: GlobalModel) => void;
let assertSeriesInitialized: (ecModel: GlobalModel) => void;
let initBase: (ecModel: GlobalModel, baseOption: ECUnitOption) => void;

const OPTION_INNER_KEY = '\0_ec_inner';
const OPTION_INNER_VALUE = 1;

const BUITIN_COMPONENTS_MAP = {
    grid: 'GridComponent',
    polar: 'PolarComponent',
    geo: 'GeoComponent',
    singleAxis: 'SingleAxisComponent',
    parallel: 'ParallelComponent',
    calendar: 'CalendarComponent',
    graphic: 'GraphicComponent',
    toolbox: 'ToolboxComponent',
    tooltip: 'TooltipComponent',
    axisPointer: 'AxisPointerComponent',
    brush: 'BrushComponent',
    title: 'TitleComponent',
    timeline: 'TimelineComponent',
    markPoint: 'MarkPointComponent',
    markLine: 'MarkLineComponent',
    markArea: 'MarkAreaComponent',
    legend: 'LegendComponent',
    dataZoom: 'DataZoomComponent',
    visualMap: 'VisualMapComponent',
    // aria: 'AriaComponent',
    // dataset: 'DatasetComponent',

    // Dependencies
    xAxis: 'GridComponent',
    yAxis: 'GridComponent',
    angleAxis: 'PolarComponent',
    radiusAxis: 'PolarComponent'
} as const;

const BUILTIN_CHARTS_MAP = {
    line: 'LineChart',
    bar: 'BarChart',
    pie: 'PieChart',
    scatter: 'ScatterChart',
    radar: 'RadarChart',
    map: 'MapChart',
    tree: 'TreeChart',
    treemap: 'TreemapChart',
    graph: 'GraphChart',
    gauge: 'GaugeChart',
    funnel: 'FunnelChart',
    parallel: 'ParallelChart',
    sankey: 'SankeyChart',
    boxplot: 'BoxplotChart',
    candlestick: 'CandlestickChart',
    effectScatter: 'EffectScatterChart',
    lines: 'LinesChart',
    heatmap: 'HeatmapChart',
    pictorialBar: 'PictorialBarChart',
    themeRiver: 'ThemeRiverChart',
    sunburst: 'SunburstChart',
    custom: 'CustomChart'
} as const;

const componetsMissingLogPrinted: Record<string, boolean> = {};

function checkMissingComponents(option: ECUnitOption) {
    each(option, function (componentOption, mainType: ComponentMainType) {
        if (!ComponentModel.hasClass(mainType)) {
            const componentImportName = BUITIN_COMPONENTS_MAP[mainType as keyof typeof BUITIN_COMPONENTS_MAP];
            if (componentImportName && !componetsMissingLogPrinted[componentImportName]) {
                error(`Component ${mainType} is used but not imported.
import { ${componentImportName} } from 'echarts/components';
echarts.use([${componentImportName}]);`);
                componetsMissingLogPrinted[componentImportName] = true;
            }
        }
    });
}

class GlobalModel extends Model<ECUnitOption> {
    // @readonly
    option: ECUnitOption;

    private _theme: Model;

    private _locale: Model;

    private _optionManager: OptionManager;

    private _componentsMap: HashMap<ComponentModel[], ComponentMainType>;

    /**
     * `_componentsMap` might have "hole" because of remove.
     * So save components count for a certain mainType here.
     */
    private _componentsCount: HashMap<number>;

    /**
     * Mapping between filtered series list and raw series list.
     * key: filtered series indices, value: raw series indices.
     * Items of `_seriesIndices` never be null/empty/-1.
     * If series has been removed by `replaceMerge`, those series
     * also won't be in `_seriesIndices`, just like be filtered.
     */
    private _seriesIndices: number[];

    /**
     * Key: seriesIndex.
     * Keep consistent with `_seriesIndices`.
     */
    private _seriesIndicesMap: HashMap<any>;

    /**
     * Model for store update payload
     */
    private _payload: Payload;

    // Injectable properties:
    scheduler: Scheduler;

    // If in ssr mode.
    // TODO put in a better place?
    ssr: boolean;

    init(
        option: ECBasicOption,
        parentModel: Model,
        ecModel: GlobalModel,
        theme: object,
        locale: object,
        optionManager: OptionManager
    ): void {
        theme = theme || {};
        this.option = null; // Mark as not initialized.
        this._theme = new Model(theme);
        this._locale = new Model(locale);
        this._optionManager = optionManager;
    }

    setOption(
        option: ECBasicOption,
        opts: GlobalModelSetOptionOpts,
        optionPreprocessorFuncs: OptionPreprocessor[]
    ): void {

        if (__DEV__) {
            assert(option != null, 'option is null/undefined');
            assert(
                option[OPTION_INNER_KEY] !== OPTION_INNER_VALUE,
                'please use chart.getOption()'
            );
        }

        const innerOpt = normalizeSetOptionInput(opts);

        this._optionManager.setOption(option, optionPreprocessorFuncs, innerOpt);

        this._resetOption(null, innerOpt);
    }

    /**
     * @param type null/undefined: reset all.
     *        'recreate': force recreate all.
     *        'timeline': only reset timeline option
     *        'media': only reset media query option
     * @return Whether option changed.
     */
    resetOption(
        type: 'recreate' | 'timeline' | 'media',
        opt?: Pick<GlobalModelSetOptionOpts, 'replaceMerge'>
    ): boolean {
        return this._resetOption(type, normalizeSetOptionInput(opt));
    }

    private _resetOption(
        type: 'recreate' | 'timeline' | 'media',
        opt: InnerSetOptionOpts
    ): boolean {
        let optionChanged = false;
        const optionManager = this._optionManager;

        if (!type || type === 'recreate') {
            const baseOption = optionManager.mountOption(type === 'recreate');
            if (__DEV__) {
                checkMissingComponents(baseOption);
            }

            if (!this.option || type === 'recreate') {
                initBase(this, baseOption);
            }
            else {
                this.restoreData();
                this._mergeOption(baseOption, opt);
            }
            optionChanged = true;
        }

        if (type === 'timeline' || type === 'media') {
            this.restoreData();
        }

        // By design, if `setOption(option2)` at the second time, and `option2` is a `ECUnitOption`,
        // it should better not have the same props with `MediaUnit['option']`.
        // Because either `option2` or `MediaUnit['option']` will be always merged to "current option"
        // rather than original "baseOption". If they both override a prop, the result might be
        // unexpected when media state changed after `setOption` called.
        // If we really need to modify a props in each `MediaUnit['option']`, use the full version
        // (`{baseOption, media}`) in `setOption`.
        // For `timeline`, the case is the same.

        if (!type || type === 'recreate' || type === 'timeline') {
            const timelineOption = optionManager.getTimelineOption(this);
            if (timelineOption) {
                optionChanged = true;
                this._mergeOption(timelineOption, opt);
            }
        }

        if (!type || type === 'recreate' || type === 'media') {
            const mediaOptions = optionManager.getMediaOption(this);
            if (mediaOptions.length) {
                each(mediaOptions, function (mediaOption) {
                    optionChanged = true;
                    this._mergeOption(mediaOption, opt);
                }, this);
            }
        }

        return optionChanged;
    }

    public mergeOption(option: ECUnitOption): void {
        this._mergeOption(option, null);
    }

    private _mergeOption(
        newOption: ECUnitOption,
        opt: InnerSetOptionOpts
    ): void {
        const option = this.option;
        const componentsMap = this._componentsMap;
        const componentsCount = this._componentsCount;
        const newCmptTypes: ComponentMainType[] = [];
        const newCmptTypeMap = createHashMap<boolean, string>();
        const replaceMergeMainTypeMap = opt && opt.replaceMergeMainTypeMap;

        resetSourceDefaulter(this);

        // If no component class, merge directly.
        // For example: color, animaiton options, etc.
        each(newOption, function (componentOption, mainType: ComponentMainType) {
            if (componentOption == null) {
                return;
            }

            if (!ComponentModel.hasClass(mainType)) {
                // globalSettingTask.dirty();
                option[mainType] = option[mainType] == null
                    ? clone(componentOption)
                    : merge(option[mainType], componentOption, true);
            }
            else if (mainType) {
                newCmptTypes.push(mainType);
                newCmptTypeMap.set(mainType, true);
            }
        });

        if (replaceMergeMainTypeMap) {
            // If there is a mainType `xxx` in `replaceMerge` but not declared in option,
            // we trade it as it is declared in option as `{xxx: []}`. Because:
            // (1) for normal merge, `{xxx: null/undefined}` are the same meaning as `{xxx: []}`.
            // (2) some preprocessor may convert some of `{xxx: null/undefined}` to `{xxx: []}`.
            replaceMergeMainTypeMap.each(function (val, mainTypeInReplaceMerge) {
                if (ComponentModel.hasClass(mainTypeInReplaceMerge) && !newCmptTypeMap.get(mainTypeInReplaceMerge)) {
                    newCmptTypes.push(mainTypeInReplaceMerge);
                    newCmptTypeMap.set(mainTypeInReplaceMerge, true);
                }
            });
        }

        (ComponentModel as ComponentModelConstructor).topologicalTravel(
            newCmptTypes,
            (ComponentModel as ComponentModelConstructor).getAllClassMainTypes(),
            visitComponent,
            this
        );

        function visitComponent(
            this: GlobalModel,
            mainType: ComponentMainType
        ): void {
            const newCmptOptionList = concatInternalOptions(
                this, mainType, modelUtil.normalizeToArray(newOption[mainType])
            );

            const oldCmptList = componentsMap.get(mainType);
            const mergeMode =
                // `!oldCmptList` means init. See the comment in `mappingToExists`
                  !oldCmptList ? 'replaceAll'
                : (replaceMergeMainTypeMap && replaceMergeMainTypeMap.get(mainType)) ? 'replaceMerge'
                : 'normalMerge';
            const mappingResult = modelUtil.mappingToExists(oldCmptList, newCmptOptionList, mergeMode);

            // Set mainType and complete subType.
            modelUtil.setComponentTypeToKeyInfo(mappingResult, mainType, ComponentModel as ComponentModelConstructor);

            // Empty it before the travel, in order to prevent `this._componentsMap`
            // from being used in the `init`/`mergeOption`/`optionUpdated` of some
            // components, which is probably incorrect logic.
            option[mainType] = null;
            componentsMap.set(mainType, null);
            componentsCount.set(mainType, 0);

            const optionsByMainType = [] as ComponentOption[];
            const cmptsByMainType = [] as ComponentModel[];
            let cmptsCountByMainType = 0;

            let tooltipExists: boolean;
            let tooltipWarningLogged: boolean;

            each(mappingResult, function (resultItem, index) {
                let componentModel = resultItem.existing;
                const newCmptOption = resultItem.newOption;

                if (!newCmptOption) {
                    if (componentModel) {
                        // Consider where is no new option and should be merged using {},
                        // see removeEdgeAndAdd in topologicalTravel and
                        // ComponentModel.getAllClassMainTypes.
                        componentModel.mergeOption({}, this);
                        componentModel.optionUpdated({}, false);
                    }
                    // If no both `resultItem.exist` and `resultItem.option`,
                    // either it is in `replaceMerge` and not matched by any id,
                    // or it has been removed in previous `replaceMerge` and left a "hole" in this component index.
                }
                else {
                    const isSeriesType = mainType === 'series';
                    const ComponentModelClass = (ComponentModel as ComponentModelConstructor).getClass(
                        mainType, resultItem.keyInfo.subType,
                        !isSeriesType // Give a more detailed warn later if series don't exists
                    );

                    if (!ComponentModelClass) {
                        if (__DEV__) {
                            const subType = resultItem.keyInfo.subType;
                            const seriesImportName = BUILTIN_CHARTS_MAP[subType as keyof typeof BUILTIN_CHARTS_MAP];
                            if (!componetsMissingLogPrinted[subType]) {
                                componetsMissingLogPrinted[subType] = true;
                                if (seriesImportName) {
                                    error(`Series ${subType} is used but not imported.
import { ${seriesImportName} } from 'echarts/charts';
echarts.use([${seriesImportName}]);`);
                                }
                                else {
                                    error(`Unknown series ${subType}`);
                                }
                            }
                        }
                        return;
                    }

                    // TODO Before multiple tooltips get supported, we do this check to avoid unexpected exception.
                    if (mainType === 'tooltip') {
                        if (tooltipExists) {
                            if (__DEV__) {
                                if (!tooltipWarningLogged) {
                                    warn('Currently only one tooltip component is allowed.');
                                    tooltipWarningLogged = true;
                                }
                            }
                            return;
                        }
                        tooltipExists = true;
                    }

                    if (componentModel && componentModel.constructor === ComponentModelClass) {
                        componentModel.name = resultItem.keyInfo.name;
                        // componentModel.settingTask && componentModel.settingTask.dirty();
                        componentModel.mergeOption(newCmptOption, this);
                        componentModel.optionUpdated(newCmptOption, false);
                    }
                    else {
                        // PENDING Global as parent ?
                        const extraOpt = extend(
                            {
                                componentIndex: index
                            },
                            resultItem.keyInfo
                        );
                        componentModel = new ComponentModelClass(
                            newCmptOption, this, this, extraOpt
                        );
                        // Assign `keyInfo`
                        extend(componentModel, extraOpt);
                        if (resultItem.brandNew) {
                            componentModel.__requireNewView = true;
                        }
                        componentModel.init(newCmptOption, this, this);

                        // Call optionUpdated after init.
                        // newCmptOption has been used as componentModel.option
                        // and may be merged with theme and default, so pass null
                        // to avoid confusion.
                        componentModel.optionUpdated(null, true);
                    }
                }

                if (componentModel) {
                    optionsByMainType.push(componentModel.option);
                    cmptsByMainType.push(componentModel);
                    cmptsCountByMainType++;
                }
                else {
                    // Always do assign to avoid elided item in array.
                    optionsByMainType.push(void 0);
                    cmptsByMainType.push(void 0);
                }
            }, this);

            option[mainType] = optionsByMainType;
            componentsMap.set(mainType, cmptsByMainType);
            componentsCount.set(mainType, cmptsCountByMainType);

            // Backup series for filtering.
            if (mainType === 'series') {
                reCreateSeriesIndices(this);
            }
        }

        // If no series declared, ensure `_seriesIndices` initialized.
        if (!this._seriesIndices) {
            reCreateSeriesIndices(this);
        }
    }

    /**
     * Get option for output (cloned option and inner info removed)
     */
    getOption(): ECUnitOption {
        const option = clone(this.option);

        each(option, function (optInMainType, mainType) {
            if (ComponentModel.hasClass(mainType)) {
                const opts = modelUtil.normalizeToArray(optInMainType);
                // Inner cmpts need to be removed.
                // Inner cmpts might not be at last since ec5.0, but still
                // compatible for users: if inner cmpt at last, splice the returned array.
                let realLen = opts.length;
                let metNonInner = false;
                for (let i = realLen - 1; i >= 0; i--) {
                    // Remove options with inner id.
                    if (opts[i] && !modelUtil.isComponentIdInternal(opts[i])) {
                        metNonInner = true;
                    }
                    else {
                        opts[i] = null;
                        !metNonInner && realLen--;
                    }
                }
                opts.length = realLen;
                option[mainType] = opts;
            }
        });

        delete option[OPTION_INNER_KEY];

        return option;
    }

    getTheme(): Model {
        return this._theme;
    }

    getLocaleModel(): Model<LocaleOption> {
        return this._locale;
    }

    setUpdatePayload(payload: Payload) {
        this._payload = payload;
    }

    getUpdatePayload(): Payload {
        return this._payload;
    }

    /**
     * @param idx If not specified, return the first one.
     */
    getComponent(mainType: ComponentMainType, idx?: number): ComponentModel {
        const list = this._componentsMap.get(mainType);
        if (list) {
            const cmpt = list[idx || 0];
            if (cmpt) {
                return cmpt;
            }
            else if (idx == null) {
                for (let i = 0; i < list.length; i++) {
                    if (list[i]) {
                        return list[i];
                    }
                }
            }
        }
    }

    /**
     * @return Never be null/undefined.
     */
    queryComponents(condition: QueryConditionKindB): ComponentModel[] {
        const mainType = condition.mainType;
        if (!mainType) {
            return [];
        }

        const index = condition.index;
        const id = condition.id;
        const name = condition.name;
        const cmpts = this._componentsMap.get(mainType);

        if (!cmpts || !cmpts.length) {
            return [];
        }

        let result: ComponentModel[];

        if (index != null) {
            result = [];
            each(modelUtil.normalizeToArray(index), function (idx) {
                cmpts[idx] && result.push(cmpts[idx]);
            });
        }
        else if (id != null) {
            result = queryByIdOrName('id', id, cmpts);
        }
        else if (name != null) {
            result = queryByIdOrName('name', name, cmpts);
        }
        else {
            // Return all non-empty components in that mainType
            result = filter(cmpts, cmpt => !!cmpt);
        }

        return filterBySubType(result, condition);
    }

    /**
     * The interface is different from queryComponents,
     * which is convenient for inner usage.
     *
     * @usage
     * let result = findComponents(
     *     {mainType: 'dataZoom', query: {dataZoomId: 'abc'}}
     * );
     * let result = findComponents(
     *     {mainType: 'series', subType: 'pie', query: {seriesName: 'uio'}}
     * );
     * let result = findComponents(
     *     {mainType: 'series',
     *     filter: function (model, index) {...}}
     * );
     * // result like [component0, componnet1, ...]
     */
    findComponents(condition: QueryConditionKindA): ComponentModel[] {
        const query = condition.query;
        const mainType = condition.mainType;

        const queryCond = getQueryCond(query);
        const result = queryCond
            ? this.queryComponents(queryCond)
            // Retrieve all non-empty components.
            : filter(this._componentsMap.get(mainType), cmpt => !!cmpt);

        return doFilter(filterBySubType(result, condition));

        function getQueryCond(q: QueryConditionKindA['query']): QueryConditionKindB {
            const indexAttr = mainType + 'Index';
            const idAttr = mainType + 'Id';
            const nameAttr = mainType + 'Name';
            return q && (
                    q[indexAttr] != null
                    || q[idAttr] != null
                    || q[nameAttr] != null
                )
                ? {
                    mainType: mainType,
                    // subType will be filtered finally.
                    index: q[indexAttr] as (number | number[]),
                    id: q[idAttr] as (OptionId | OptionId[]),
                    name: q[nameAttr] as (OptionName | OptionName[])
                }
                : null;
        }

        function doFilter(res: ComponentModel[]) {
            return condition.filter
                    ? filter(res, condition.filter)
                    : res;
        }
    }

    /**
     * Travel components (before filtered).
     *
     * @usage
     * eachComponent('legend', function (legendModel, index) {
     *     ...
     * });
     * eachComponent(function (componentType, model, index) {
     *     // componentType does not include subType
     *     // (componentType is 'a' but not 'a.b')
     * });
     * eachComponent(
     *     {mainType: 'dataZoom', query: {dataZoomId: 'abc'}},
     *     function (model, index) {...}
     * );
     * eachComponent(
     *     {mainType: 'series', subType: 'pie', query: {seriesName: 'uio'}},
     *     function (model, index) {...}
     * );
     */
    eachComponent<T>(
        cb: EachComponentAllCallback,
        context?: T
    ): void;
    eachComponent<T>(
        mainType: string,
        cb: EachComponentInMainTypeCallback,
        context?: T
    ): void;
    eachComponent<T>(
        mainType: QueryConditionKindA,
        cb: EachComponentInMainTypeCallback,
        context?: T
    ): void;
    eachComponent<T>(
        mainType: string | QueryConditionKindA | EachComponentAllCallback,
        cb?: EachComponentInMainTypeCallback | T,
        context?: T
    ) {
        const componentsMap = this._componentsMap;

        if (isFunction(mainType)) {
            const ctxForAll = cb as T;
            const cbForAll = mainType as EachComponentAllCallback;
            componentsMap.each(function (cmpts, componentType) {
                for (let i = 0; cmpts && i < cmpts.length; i++) {
                    const cmpt = cmpts[i];
                    cmpt && cbForAll.call(ctxForAll, componentType, cmpt, cmpt.componentIndex);
                }
            });
        }
        else {
            const cmpts = isString(mainType)
                ? componentsMap.get(mainType)
                : isObject(mainType)
                ? this.findComponents(mainType)
                : null;
            for (let i = 0; cmpts && i < cmpts.length; i++) {
                const cmpt = cmpts[i];
                cmpt && (cb as EachComponentInMainTypeCallback).call(
                    context, cmpt, cmpt.componentIndex
                );
            }
        }
    }

    /**
     * Get series list before filtered by name.
     */
    getSeriesByName(name: OptionName): SeriesModel[] {
        const nameStr = modelUtil.convertOptionIdName(name, null);
        return filter(
            this._componentsMap.get('series') as SeriesModel[],
            oneSeries => !!oneSeries && nameStr != null && oneSeries.name === nameStr
        );
    }

    /**
     * Get series list before filtered by index.
     */
    getSeriesByIndex(seriesIndex: number): SeriesModel {
        return this._componentsMap.get('series')[seriesIndex] as SeriesModel;
    }

    /**
     * Get series list before filtered by type.
     * FIXME: rename to getRawSeriesByType?
     */
    getSeriesByType(subType: ComponentSubType): SeriesModel[] {
        return filter(
            this._componentsMap.get('series') as SeriesModel[],
            oneSeries => !!oneSeries && oneSeries.subType === subType
        );
    }

    /**
     * Get all series before filtered.
     */
    getSeries(): SeriesModel[] {
        return filter(
            this._componentsMap.get('series') as SeriesModel[],
            oneSeries => !!oneSeries
        );
    }

    /**
     * Count series before filtered.
     */
    getSeriesCount(): number {
        return this._componentsCount.get('series');
    }

    /**
     * After filtering, series may be different
     * from raw series.
     */
    eachSeries<T>(
        cb: (this: T, series: SeriesModel, rawSeriesIndex: number) => void,
        context?: T
    ): void {
        assertSeriesInitialized(this);
        each(this._seriesIndices, function (rawSeriesIndex) {
            const series = this._componentsMap.get('series')[rawSeriesIndex] as SeriesModel;
            cb.call(context, series, rawSeriesIndex);
        }, this);
    }

    /**
     * Iterate raw series before filtered.
     *
     * @param {Function} cb
     * @param {*} context
     */
    eachRawSeries<T>(
        cb: (this: T, series: SeriesModel, rawSeriesIndex: number) => void,
        context?: T
    ): void {
        each(this._componentsMap.get('series'), function (series) {
            series && cb.call(context, series, series.componentIndex);
        });
    }

    /**
     * After filtering, series may be different.
     * from raw series.
     */
    eachSeriesByType<T>(
        subType: ComponentSubType,
        cb: (this: T, series: SeriesModel, rawSeriesIndex: number) => void,
        context?: T
    ): void {
        assertSeriesInitialized(this);
        each(this._seriesIndices, function (rawSeriesIndex) {
            const series = this._componentsMap.get('series')[rawSeriesIndex] as SeriesModel;
            if (series.subType === subType) {
                cb.call(context, series, rawSeriesIndex);
            }
        }, this);
    }

    /**
     * Iterate raw series before filtered of given type.
     */
    eachRawSeriesByType<T>(
        subType: ComponentSubType,
        cb: (this: T, series: SeriesModel, rawSeriesIndex: number) => void,
        context?: T
    ): void {
        return each(this.getSeriesByType(subType), cb, context);
    }

    isSeriesFiltered(seriesModel: SeriesModel): boolean {
        assertSeriesInitialized(this);
        return this._seriesIndicesMap.get(seriesModel.componentIndex) == null;
    }

    getCurrentSeriesIndices(): number[] {
        return (this._seriesIndices || []).slice();
    }

    filterSeries<T>(
        cb: (this: T, series: SeriesModel, rawSeriesIndex: number) => boolean,
        context?: T
    ): void {
        assertSeriesInitialized(this);

        const newSeriesIndices: number[] = [];
        each(this._seriesIndices, function (seriesRawIdx) {
            const series = this._componentsMap.get('series')[seriesRawIdx] as SeriesModel;
            cb.call(context, series, seriesRawIdx) && newSeriesIndices.push(seriesRawIdx);
        }, this);

        this._seriesIndices = newSeriesIndices;
        this._seriesIndicesMap = createHashMap(newSeriesIndices);
    }

    restoreData(payload?: Payload): void {

        reCreateSeriesIndices(this);

        const componentsMap = this._componentsMap;
        const componentTypes: string[] = [];
        componentsMap.each(function (components, componentType) {
            if (ComponentModel.hasClass(componentType)) {
                componentTypes.push(componentType);
            }
        });

        (ComponentModel as ComponentModelConstructor).topologicalTravel(
            componentTypes,
            (ComponentModel as ComponentModelConstructor).getAllClassMainTypes(),
            function (componentType) {
                each(componentsMap.get(componentType), function (component) {
                    if (component
                        && (
                            componentType !== 'series'
                            || !isNotTargetSeries(component as SeriesModel, payload)
                        )
                    ) {
                        component.restoreData();
                    }
                });
            }
        );
    }

    private static internalField = (function () {

        reCreateSeriesIndices = function (ecModel: GlobalModel): void {
            const seriesIndices: number[] = ecModel._seriesIndices = [];
            each(ecModel._componentsMap.get('series'), function (series) {
                // series may have been removed by `replaceMerge`.
                series && seriesIndices.push(series.componentIndex);
            });
            ecModel._seriesIndicesMap = createHashMap(seriesIndices);
        };

        assertSeriesInitialized = function (ecModel: GlobalModel): void {
            // Components that use _seriesIndices should depends on series component,
            // which make sure that their initialization is after series.
            if (__DEV__) {
                if (!ecModel._seriesIndices) {
                    throw new Error('Option should contains series.');
                }
            }
        };

        initBase = function (ecModel: GlobalModel, baseOption: ECUnitOption & AriaOptionMixin): void {
            // Using OPTION_INNER_KEY to mark that this option cannot be used outside,
            // i.e. `chart.setOption(chart.getModel().option);` is forbidden.
            ecModel.option = {} as ECUnitOption;
            ecModel.option[OPTION_INNER_KEY] = OPTION_INNER_VALUE;

            // Init with series: [], in case of calling findSeries method
            // before series initialized.
            ecModel._componentsMap = createHashMap({series: []});
            ecModel._componentsCount = createHashMap();

            // If user spefied `option.aria`, aria will be enable. This detection should be
            // performed before theme and globalDefault merge.
            const airaOption = baseOption.aria;
            if (isObject(airaOption) && airaOption.enabled == null) {
                airaOption.enabled = true;
            }

            mergeTheme(baseOption, ecModel._theme.option);

            // TODO Needs clone when merging to the unexisted property
            merge(baseOption, globalDefault, false);

            ecModel._mergeOption(baseOption, null);
        };

    })();
}


/**
 * @param condition.mainType Mandatory.
 * @param condition.subType Optional.
 * @param condition.query like {xxxIndex, xxxId, xxxName},
 *        where xxx is mainType.
 *        If query attribute is null/undefined or has no index/id/name,
 *        do not filtering by query conditions, which is convenient for
 *        no-payload situations or when target of action is global.
 * @param condition.filter parameter: component, return boolean.
 */
export interface QueryConditionKindA {
    mainType: ComponentMainType;
    subType?: ComponentSubType;
    query?: {
        [k: string]: number | number[] | string | string[]
    };
    filter?: (cmpt: ComponentModel) => boolean;
}

/**
 * If none of index and id and name used, return all components with mainType.
 * @param condition.mainType
 * @param condition.subType If ignore, only query by mainType
 * @param condition.index Either input index or id or name.
 * @param condition.id Either input index or id or name.
 * @param condition.name Either input index or id or name.
 */
export interface QueryConditionKindB {
    mainType: ComponentMainType;
    subType?: ComponentSubType;
    index?: number | number[];
    id?: OptionId | OptionId[];
    name?: OptionName | OptionName[];
}
export interface EachComponentAllCallback {
    (mainType: string, model: ComponentModel, componentIndex: number): void;
}
interface EachComponentInMainTypeCallback {
    (model: ComponentModel, componentIndex: number): void;
}


function isNotTargetSeries(seriesModel: SeriesModel, payload: Payload): boolean {
    if (payload) {
        const index = payload.seriesIndex;
        const id = payload.seriesId;
        const name = payload.seriesName;
        return (index != null && seriesModel.componentIndex !== index)
            || (id != null && seriesModel.id !== id)
            || (name != null && seriesModel.name !== name);
    }
}

function mergeTheme(option: ECUnitOption, theme: ThemeOption): void {
    // PENDING
    // NOT use `colorLayer` in theme if option has `color`
    const notMergeColorLayer = option.color && !option.colorLayer;

    each(theme, function (themeItem, name) {
        if (name === 'colorLayer' && notMergeColorLayer) {
            return;
        }

        // If it is component model mainType, the model handles that merge later.
        // otherwise, merge them here.
        if (!ComponentModel.hasClass(name)) {
            if (typeof themeItem === 'object') {
                option[name] = !option[name]
                    ? clone(themeItem)
                    : merge(option[name], themeItem, false);
            }
            else {
                if (option[name] == null) {
                    option[name] = themeItem;
                }
            }
        }
    });
}

function queryByIdOrName<T extends { id?: string, name?: string }>(
    attr: 'id' | 'name',
    idOrName: string | number | (string | number)[],
    cmpts: T[]
): T[] {
    // Here is a break from echarts4: string and number are
    // treated as equal.
    if (isArray(idOrName)) {
        const keyMap = createHashMap<boolean>();
        each(idOrName, function (idOrNameItem) {
            if (idOrNameItem != null) {
                const idName = modelUtil.convertOptionIdName(idOrNameItem, null);
                idName != null && keyMap.set(idOrNameItem, true);
            }
        });
        return filter(cmpts, cmpt => cmpt && keyMap.get(cmpt[attr]));
    }
    else {
        const idName = modelUtil.convertOptionIdName(idOrName, null);
        return filter(cmpts, cmpt => cmpt && idName != null && cmpt[attr] === idName);
    }
}

function filterBySubType(
    components: ComponentModel[],
    condition: QueryConditionKindA | QueryConditionKindB
): ComponentModel[] {
    // Using hasOwnProperty for restrict. Consider
    // subType is undefined in user payload.
    return condition.hasOwnProperty('subType')
        ? filter(components, cmpt => cmpt && cmpt.subType === condition.subType)
        : components;
}

function normalizeSetOptionInput(opts: GlobalModelSetOptionOpts): InnerSetOptionOpts {
    const replaceMergeMainTypeMap = createHashMap<boolean, string>();
    opts && each(modelUtil.normalizeToArray(opts.replaceMerge), function (mainType) {
        if (__DEV__) {
            assert(
                ComponentModel.hasClass(mainType),
                '"' + mainType + '" is not valid component main type in "replaceMerge"'
            );
        }
        replaceMergeMainTypeMap.set(mainType, true);
    });
    return {
        replaceMergeMainTypeMap: replaceMergeMainTypeMap
    };
}

interface GlobalModel extends PaletteMixin<ECUnitOption> {}
mixin(GlobalModel, PaletteMixin);

export default GlobalModel;
