/*
* 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.
*/
import { bind, each, clone, trim, isString, isFunction, isArray, isObject, extend } from 'zrender/src/core/util';
import env from 'zrender/src/core/env';
import TooltipHTMLContent from './TooltipHTMLContent';
import TooltipRichContent from './TooltipRichContent';
import { convertToColorString, formatTpl, TooltipMarker } from '../../util/format';
import { parsePercent } from '../../util/number';
import { Rect } from '../../util/graphic';
import findPointFromSeries from '../axisPointer/findPointFromSeries';
import { getLayoutRect } from '../../util/layout';
import Model from '../../model/Model';
import * as globalListener from '../axisPointer/globalListener';
import * as axisHelper from '../../coord/axisHelper';
import * as axisPointerViewHelper from '../axisPointer/viewHelper';
import { getTooltipRenderMode, preParseFinder, queryReferringComponents } from '../../util/model';
import ComponentView from '../../view/Component';
import { format as timeFormat } from '../../util/time';
import {
    HorizontalAlign,
    VerticalAlign,
    ZRRectLike,
    BoxLayoutOptionMixin,
    CallbackDataParams,
    TooltipRenderMode,
    ECElement,
    CommonTooltipOption,
    ZRColor,
    ComponentMainType,
    ComponentItemTooltipOption
} from '../../util/types';
import GlobalModel from '../../model/Global';
import ExtensionAPI from '../../core/ExtensionAPI';
import TooltipModel, { TooltipOption } from './TooltipModel';
import Element from 'zrender/src/Element';
import { AxisBaseModel } from '../../coord/AxisBaseModel';
import { ECData, getECData } from '../../util/innerStore';
import { shouldTooltipConfine } from './helper';
import { DataByCoordSys, DataByAxis } from '../axisPointer/axisTrigger';
import { normalizeTooltipFormatResult } from '../../model/mixin/dataFormat';
import { createTooltipMarkup, buildTooltipMarkup, TooltipMarkupStyleCreator } from './tooltipMarkup';
import { findEventDispatcher } from '../../util/event';
import { clear, createOrUpdate } from '../../util/throttle';

const proxyRect = new Rect({
    shape: { x: -1, y: -1, width: 2, height: 2 }
});

interface DataIndex {
    seriesIndex: number
    dataIndex: number

    dataIndexInside: number
}

interface ShowTipPayload {
    type?: 'showTip'
    from?: string

    // Type 1
    tooltip?: ECData['tooltipConfig']['option']

    // Type 2
    dataByCoordSys?: DataByCoordSys[]
    tooltipOption?: CommonTooltipOption<TooltipCallbackDataParams | TooltipCallbackDataParams[]>

    // Type 3
    seriesIndex?: number
    dataIndex?: number

    // Type 4
    name?: string // target item name that enable tooltip.
    // legendIndex: 0,
    // toolboxId: 'some_id',
    // geoName: 'some_name',

    x?: number
    y?: number
    position?: TooltipOption['position']

    dispatchAction?: ExtensionAPI['dispatchAction']
}

interface HideTipPayload {
    type?: 'hideTip'
    from?: string

    dispatchAction?: ExtensionAPI['dispatchAction']
}

interface TryShowParams {
    target?: ECElement,

    offsetX?: number
    offsetY?: number

    /**
     * Used for axis trigger.
     */
    dataByCoordSys?: DataByCoordSys[]

    tooltipOption?: ComponentItemTooltipOption<TooltipCallbackDataParams | TooltipCallbackDataParams[]>

    position?: TooltipOption['position']
    /**
     * If `position` is not set in payload nor option, use it.
     */
    positionDefault?: TooltipOption['position']
}

type TooltipCallbackDataParams = CallbackDataParams & {
    axisDim?: string
    axisIndex?: number
    axisType?: string
    axisId?: string
    // TODO: TYPE Value type
    axisValue?: string | number
    axisValueLabel?: string
    marker?: TooltipMarker
};

class TooltipView extends ComponentView {
    static type = 'tooltip' as const;
    type = TooltipView.type;

    private _renderMode: TooltipRenderMode;

    private _tooltipModel: TooltipModel;

    private _ecModel: GlobalModel;

    private _api: ExtensionAPI;

    private _tooltipContent: TooltipHTMLContent | TooltipRichContent;

    private _refreshUpdateTimeout: number;

    private _lastX: number;
    private _lastY: number;

    private _ticket: string;

    private _showTimout: number;

    private _lastDataByCoordSys: DataByCoordSys[];
    private _cbParamsList: TooltipCallbackDataParams[];

    init(ecModel: GlobalModel, api: ExtensionAPI) {
        if (env.node || !api.getDom()) {
            return;
        }

        const tooltipModel = ecModel.getComponent('tooltip') as TooltipModel;
        const renderMode = this._renderMode = getTooltipRenderMode(tooltipModel.get('renderMode'));

        this._tooltipContent = renderMode === 'richText'
            ? new TooltipRichContent(api)
            : new TooltipHTMLContent(api, {
                appendTo: tooltipModel.get('appendToBody', true) ? 'body' : tooltipModel.get('appendTo', true)
            });
    }

    render(
        tooltipModel: TooltipModel,
        ecModel: GlobalModel,
        api: ExtensionAPI
    ) {
        if (env.node || !api.getDom()) {
            return;
        }

        // Reset
        this.group.removeAll();

        this._tooltipModel = tooltipModel;

        this._ecModel = ecModel;

        this._api = api;

        const tooltipContent = this._tooltipContent;
        tooltipContent.update(tooltipModel);
        tooltipContent.setEnterable(tooltipModel.get('enterable'));

        this._initGlobalListener();

        this._keepShow();

        // PENDING
        // `mousemove` event will be triggered very frequently when the mouse moves fast,
        // which causes that the `updatePosition` function was also called frequently.
        // In Chrome with devtools open and Firefox, tooltip looks laggy and shakes. See #14695 #16101
        // To avoid frequent triggering,
        // consider throttling it in 50ms when transition is enabled
        if (this._renderMode !== 'richText' && tooltipModel.get('transitionDuration')) {
            createOrUpdate(this, '_updatePosition', 50, 'fixRate');
        }
        else {
            clear(this, '_updatePosition');
        }
    }

    private _initGlobalListener() {
        const tooltipModel = this._tooltipModel;
        const triggerOn = tooltipModel.get('triggerOn');

        globalListener.register(
            'itemTooltip',
            this._api,
            bind(function (currTrigger, e, dispatchAction) {
                // If 'none', it is not controlled by mouse totally.
                if (triggerOn !== 'none') {
                    if (triggerOn.indexOf(currTrigger) >= 0) {
                        this._tryShow(e, dispatchAction);
                    }
                    else if (currTrigger === 'leave') {
                        this._hide(dispatchAction);
                    }
                }
            }, this)
        );
    }

    private _keepShow() {
        const tooltipModel = this._tooltipModel;
        const ecModel = this._ecModel;
        const api = this._api;
        const triggerOn = tooltipModel.get('triggerOn');

        // Try to keep the tooltip show when refreshing
        if (this._lastX != null
            && this._lastY != null
            // When user is willing to control tooltip totally using API,
            // self.manuallyShowTip({x, y}) might cause tooltip hide,
            // which is not expected.
            && triggerOn !== 'none'
            && triggerOn !== 'click'
        ) {
            const self = this;
            clearTimeout(this._refreshUpdateTimeout);
            this._refreshUpdateTimeout = setTimeout(function () {
                // Show tip next tick after other charts are rendered
                // In case highlight action has wrong result
                // FIXME
                !api.isDisposed() && self.manuallyShowTip(tooltipModel, ecModel, api, {
                    x: self._lastX,
                    y: self._lastY,
                    dataByCoordSys: self._lastDataByCoordSys
                });
            }) as any;
        }
    }

    /**
     * Show tip manually by
     * dispatchAction({
     *     type: 'showTip',
     *     x: 10,
     *     y: 10
     * });
     * Or
     * dispatchAction({
     *      type: 'showTip',
     *      seriesIndex: 0,
     *      dataIndex or dataIndexInside or name
     * });
     *
     *  TODO Batch
     */
    manuallyShowTip(
        tooltipModel: TooltipModel,
        ecModel: GlobalModel,
        api: ExtensionAPI,
        payload: ShowTipPayload
    ) {
        if (payload.from === this.uid || env.node || !api.getDom()) {
            return;
        }

        const dispatchAction = makeDispatchAction(payload, api);

        // Reset ticket
        this._ticket = '';

        // When triggered from axisPointer.
        const dataByCoordSys = payload.dataByCoordSys;

        const cmptRef = findComponentReference(payload, ecModel, api);

        if (cmptRef) {
            const rect = cmptRef.el.getBoundingRect().clone();
            rect.applyTransform(cmptRef.el.transform);
            this._tryShow({
                offsetX: rect.x + rect.width / 2,
                offsetY: rect.y + rect.height / 2,
                target: cmptRef.el,
                position: payload.position,
                // When manully trigger, the mouse is not on the el, so we'd better to
                // position tooltip on the bottom of the el and display arrow is possible.
                positionDefault: 'bottom'
            }, dispatchAction);
        }
        else if (payload.tooltip && payload.x != null && payload.y != null) {
            const el = proxyRect as unknown as ECElement;
            el.x = payload.x;
            el.y = payload.y;
            el.update();
            getECData(el).tooltipConfig = {
                name: null,
                option: payload.tooltip
            };
            // Manually show tooltip while view is not using zrender elements.
            this._tryShow({
                offsetX: payload.x,
                offsetY: payload.y,
                target: el
            }, dispatchAction);
        }
        else if (dataByCoordSys) {
            this._tryShow({
                offsetX: payload.x,
                offsetY: payload.y,
                position: payload.position,
                dataByCoordSys: dataByCoordSys,
                tooltipOption: payload.tooltipOption
            }, dispatchAction);
        }
        else if (payload.seriesIndex != null) {

            if (this._manuallyAxisShowTip(tooltipModel, ecModel, api, payload)) {
                return;
            }

            const pointInfo = findPointFromSeries(payload, ecModel);
            const cx = pointInfo.point[0];
            const cy = pointInfo.point[1];
            if (cx != null && cy != null) {
                this._tryShow({
                    offsetX: cx,
                    offsetY: cy,
                    target: pointInfo.el,
                    position: payload.position,
                    // When manully trigger, the mouse is not on the el, so we'd better to
                    // position tooltip on the bottom of the el and display arrow is possible.
                    positionDefault: 'bottom'
                }, dispatchAction);
            }
        }
        else if (payload.x != null && payload.y != null) {
            // FIXME
            // should wrap dispatchAction like `axisPointer/globalListener` ?
            api.dispatchAction({
                type: 'updateAxisPointer',
                x: payload.x,
                y: payload.y
            });

            this._tryShow({
                offsetX: payload.x,
                offsetY: payload.y,
                position: payload.position,
                target: api.getZr().findHover(payload.x, payload.y).target
            }, dispatchAction);
        }
    }

    manuallyHideTip(
        tooltipModel: TooltipModel,
        ecModel: GlobalModel,
        api: ExtensionAPI,
        payload: HideTipPayload
    ) {
        const tooltipContent = this._tooltipContent;

        if (this._tooltipModel) {
            tooltipContent.hideLater(this._tooltipModel.get('hideDelay'));
        }

        this._lastX = this._lastY = this._lastDataByCoordSys = null;

        if (payload.from !== this.uid) {
            this._hide(makeDispatchAction(payload, api));
        }
    }

    // Be compatible with previous design, that is, when tooltip.type is 'axis' and
    // dispatchAction 'showTip' with seriesIndex and dataIndex will trigger axis pointer
    // and tooltip.
    private _manuallyAxisShowTip(
        tooltipModel: TooltipModel,
        ecModel: GlobalModel,
        api: ExtensionAPI,
        payload: ShowTipPayload
    ) {
        const seriesIndex = payload.seriesIndex;
        const dataIndex = payload.dataIndex;
        // @ts-ignore
        const coordSysAxesInfo = ecModel.getComponent('axisPointer').coordSysAxesInfo;

        if (seriesIndex == null || dataIndex == null || coordSysAxesInfo == null) {
            return;
        }

        const seriesModel = ecModel.getSeriesByIndex(seriesIndex);
        if (!seriesModel) {
            return;
        }

        const data = seriesModel.getData();
        const tooltipCascadedModel = buildTooltipModel([
            data.getItemModel<TooltipableOption>(dataIndex),
            seriesModel as Model<TooltipableOption>,
            (seriesModel.coordinateSystem || {}).model as Model<TooltipableOption>
        ], this._tooltipModel);

        if (tooltipCascadedModel.get('trigger') !== 'axis') {
            return;
        }

        api.dispatchAction({
            type: 'updateAxisPointer',
            seriesIndex: seriesIndex,
            dataIndex: dataIndex,
            position: payload.position
        });

        return true;
    }

    private _tryShow(
        e: TryShowParams,
        dispatchAction: ExtensionAPI['dispatchAction']
    ) {
        const el = e.target;
        const tooltipModel = this._tooltipModel;

        if (!tooltipModel) {
            return;
        }

        // Save mouse x, mouse y. So we can try to keep showing the tip if chart is refreshed
        this._lastX = e.offsetX;
        this._lastY = e.offsetY;

        const dataByCoordSys = e.dataByCoordSys;
        if (dataByCoordSys && dataByCoordSys.length) {
            this._showAxisTooltip(dataByCoordSys, e);
        }
        else if (el) {
            const ecData = getECData(el);
            if (ecData.ssrType === 'legend') {
                // Don't trigger tooltip for legend tooltip item
                return;
            }
            this._lastDataByCoordSys = null;

            let seriesDispatcher: Element;
            let cmptDispatcher: Element;
            findEventDispatcher(el, (target) => {
                // Always show item tooltip if mouse is on the element with dataIndex
                if (getECData(target).dataIndex != null) {
                    seriesDispatcher = target;
                    return true;
                }
                // Tooltip provided directly. Like legend.
                if (getECData(target).tooltipConfig != null) {
                    cmptDispatcher = target;
                    return true;
                }
            }, true);

            if (seriesDispatcher) {
                this._showSeriesItemTooltip(e, seriesDispatcher, dispatchAction);
            }
            else if (cmptDispatcher) {
                this._showComponentItemTooltip(e, cmptDispatcher, dispatchAction);
            }
            else {
                this._hide(dispatchAction);
            }
        }
        else {
            this._lastDataByCoordSys = null;
            this._hide(dispatchAction);
        }
    }

    private _showOrMove(
        tooltipModel: Model<TooltipOption>,
        cb: () => void
    ) {
        // showDelay is used in this case: tooltip.enterable is set
        // as true. User intent to move mouse into tooltip and click
        // something. `showDelay` makes it easier to enter the content
        // but tooltip do not move immediately.
        const delay = tooltipModel.get('showDelay');
        cb = bind(cb, this);
        clearTimeout(this._showTimout);
        delay > 0
            ? (this._showTimout = setTimeout(cb, delay) as any)
            : cb();
    }

    private _showAxisTooltip(
        dataByCoordSys: DataByCoordSys[],
        e: TryShowParams
    ) {
        const ecModel = this._ecModel;
        const globalTooltipModel = this._tooltipModel;
        const point = [e.offsetX, e.offsetY];
        const singleTooltipModel = buildTooltipModel(
            [e.tooltipOption],
            globalTooltipModel
        );
        const renderMode = this._renderMode;
        const cbParamsList: TooltipCallbackDataParams[] = [];
        const articleMarkup = createTooltipMarkup('section', {
            blocks: [],
            noHeader: true
        });
        // Only for legacy: `Serise['formatTooltip']` returns a string.
        const markupTextArrLegacy: string[] = [];
        const markupStyleCreator = new TooltipMarkupStyleCreator();

        each(dataByCoordSys, function (itemCoordSys) {
            each(itemCoordSys.dataByAxis, function (axisItem) {
                const axisModel = ecModel.getComponent(axisItem.axisDim + 'Axis', axisItem.axisIndex) as AxisBaseModel;
                const axisValue = axisItem.value;
                if (!axisModel || axisValue == null) {
                    return;
                }
                const axisValueLabel = axisPointerViewHelper.getValueLabel(
                    axisValue, axisModel.axis, ecModel,
                    axisItem.seriesDataIndices,
                    axisItem.valueLabelOpt
                );
                const axisSectionMarkup = createTooltipMarkup('section', {
                    header: axisValueLabel,
                    noHeader: !trim(axisValueLabel),
                    sortBlocks: true,
                    blocks: []
                });
                articleMarkup.blocks.push(axisSectionMarkup);

                each(axisItem.seriesDataIndices, function (idxItem) {
                    const series = ecModel.getSeriesByIndex(idxItem.seriesIndex);
                    const dataIndex = idxItem.dataIndexInside;
                    const cbParams = series.getDataParams(dataIndex) as TooltipCallbackDataParams;
                    // Can't find data.
                    if (cbParams.dataIndex < 0) {
                        return;
                    }

                    cbParams.axisDim = axisItem.axisDim;
                    cbParams.axisIndex = axisItem.axisIndex;
                    cbParams.axisType = axisItem.axisType;
                    cbParams.axisId = axisItem.axisId;
                    cbParams.axisValue = axisHelper.getAxisRawValue(
                        axisModel.axis, { value: axisValue as number }
                    );
                    cbParams.axisValueLabel = axisValueLabel;
                    // Pre-create marker style for makers. Users can assemble richText
                    // text in `formatter` callback and use those markers style.
                    cbParams.marker = markupStyleCreator.makeTooltipMarker(
                        'item', convertToColorString(cbParams.color), renderMode
                    );

                    const seriesTooltipResult = normalizeTooltipFormatResult(
                        series.formatTooltip(dataIndex, true, null)
                    );
                    const frag = seriesTooltipResult.frag;
                    if (frag) {
                        const valueFormatter = buildTooltipModel(
                            [series as Model<TooltipableOption>],
                            globalTooltipModel
                        ).get('valueFormatter');
                        axisSectionMarkup.blocks.push(valueFormatter ? extend({ valueFormatter }, frag) : frag);
                    }
                    if (seriesTooltipResult.text) {
                        markupTextArrLegacy.push(seriesTooltipResult.text);
                    }
                    cbParamsList.push(cbParams);
                });
            });
        });

        // In most cases, the second axis is displays upper on the first one.
        // So we reverse it to look better.
        articleMarkup.blocks.reverse();
        markupTextArrLegacy.reverse();

        const positionExpr = e.position;
        const orderMode = singleTooltipModel.get('order');

        const builtMarkupText = buildTooltipMarkup(
            articleMarkup, markupStyleCreator, renderMode, orderMode, ecModel.get('useUTC'),
            singleTooltipModel.get('textStyle')
        );
        builtMarkupText && markupTextArrLegacy.unshift(builtMarkupText);
        const blockBreak = renderMode === 'richText' ? '\n\n' : '<br/>';
        const allMarkupText = markupTextArrLegacy.join(blockBreak);

        this._showOrMove(singleTooltipModel, function (this: TooltipView) {
            if (this._updateContentNotChangedOnAxis(dataByCoordSys, cbParamsList)) {
                this._updatePosition(
                    singleTooltipModel,
                    positionExpr,
                    point[0], point[1],
                    this._tooltipContent,
                    cbParamsList
                );
            }
            else {
                this._showTooltipContent(
                    singleTooltipModel, allMarkupText, cbParamsList, Math.random() + '',
                    point[0], point[1], positionExpr, null, markupStyleCreator
                );
            }
        });

        // Do not trigger events here, because this branch only be entered
        // from dispatchAction.
    }

    private _showSeriesItemTooltip(
        e: TryShowParams,
        dispatcher: ECElement,
        dispatchAction: ExtensionAPI['dispatchAction']
    ) {
        const ecModel = this._ecModel;
        const ecData = getECData(dispatcher);
        // Use dataModel in element if possible
        // Used when mouseover on a element like markPoint or edge
        // In which case, the data is not main data in series.
        const seriesIndex = ecData.seriesIndex;
        const seriesModel = ecModel.getSeriesByIndex(seriesIndex);

        // For example, graph link.
        const dataModel = ecData.dataModel || seriesModel;
        const dataIndex = ecData.dataIndex;
        const dataType = ecData.dataType;
        const data = dataModel.getData(dataType);
        const renderMode = this._renderMode;

        const positionDefault = e.positionDefault;
        const tooltipModel = buildTooltipModel(
            [
                data.getItemModel<TooltipableOption>(dataIndex),
                dataModel,
                seriesModel && (seriesModel.coordinateSystem || {}).model as Model<TooltipableOption>
            ],
            this._tooltipModel,
            positionDefault ? { position: positionDefault } : null
        );

        const tooltipTrigger = tooltipModel.get('trigger');
        if (tooltipTrigger != null && tooltipTrigger !== 'item') {
            return;
        }

        const params = dataModel.getDataParams(dataIndex, dataType);
        const markupStyleCreator = new TooltipMarkupStyleCreator();
        // Pre-create marker style for makers. Users can assemble richText
        // text in `formatter` callback and use those markers style.
        params.marker = markupStyleCreator.makeTooltipMarker(
            'item', convertToColorString(params.color), renderMode
        );

        const seriesTooltipResult = normalizeTooltipFormatResult(
            dataModel.formatTooltip(dataIndex, false, dataType)
        );
        const orderMode = tooltipModel.get('order');
        const valueFormatter = tooltipModel.get('valueFormatter');
        const frag = seriesTooltipResult.frag;
        const markupText = frag ? buildTooltipMarkup(
                valueFormatter ? extend({ valueFormatter }, frag) : frag,
                markupStyleCreator,
                renderMode,
                orderMode,
                ecModel.get('useUTC'),
                tooltipModel.get('textStyle')
            )
            : seriesTooltipResult.text;

        const asyncTicket = 'item_' + dataModel.name + '_' + dataIndex;

        this._showOrMove(tooltipModel, function (this: TooltipView) {
            this._showTooltipContent(
                tooltipModel, markupText, params, asyncTicket,
                e.offsetX, e.offsetY, e.position, e.target,
                markupStyleCreator
            );
        });

        // FIXME
        // duplicated showtip if manuallyShowTip is called from dispatchAction.
        dispatchAction({
            type: 'showTip',
            dataIndexInside: dataIndex,
            dataIndex: data.getRawIndex(dataIndex),
            seriesIndex: seriesIndex,
            from: this.uid
        });
    }

    private _showComponentItemTooltip(
        e: TryShowParams,
        el: ECElement,
        dispatchAction: ExtensionAPI['dispatchAction']
    ) {
        const ecData = getECData(el);
        const tooltipConfig = ecData.tooltipConfig;
        let tooltipOpt = tooltipConfig.option || {};
        if (isString(tooltipOpt)) {
            const content = tooltipOpt;
            tooltipOpt = {
                content: content,
                // Fixed formatter
                formatter: content
            };
        }

        const tooltipModelCascade = [tooltipOpt] as TooltipModelOptionCascade[];
        const cmpt = this._ecModel.getComponent(ecData.componentMainType, ecData.componentIndex);
        if (cmpt) {
            tooltipModelCascade.push(cmpt as Model<TooltipableOption>);
        }
        // In most cases, component tooltip formatter has different params with series tooltip formatter,
        // so that they cannot share the same formatter. Since the global tooltip formatter is used for series
        // by convention, we do not use it as the default formatter for component.
        tooltipModelCascade.push({ formatter: tooltipOpt.content });

        const positionDefault = e.positionDefault;
        const subTooltipModel = buildTooltipModel(
            tooltipModelCascade,
            this._tooltipModel,
            positionDefault ? { position: positionDefault } : null
        );

        const defaultHtml = subTooltipModel.get('content');
        const asyncTicket = Math.random() + '';
        // PENDING: this case do not support richText style yet.
        const markupStyleCreator = new TooltipMarkupStyleCreator();

        // Do not check whether `trigger` is 'none' here, because `trigger`
        // only works on coordinate system. In fact, we have not found case
        // that requires setting `trigger` nothing on component yet.

        this._showOrMove(subTooltipModel, function (this: TooltipView) {
            // Use formatterParams from element defined in component
            // Avoid users modify it.
            const formatterParams = clone(subTooltipModel.get('formatterParams') as any || {});
            this._showTooltipContent(
                subTooltipModel, defaultHtml, formatterParams,
                asyncTicket, e.offsetX, e.offsetY, e.position, el, markupStyleCreator
            );
        });

        // If not dispatch showTip, tip may be hide triggered by axis.
        dispatchAction({
            type: 'showTip',
            from: this.uid
        });
    }

    private _showTooltipContent(
        // Use Model<TooltipOption> insteadof TooltipModel because this model may be from series or other options.
        // Instead of top level tooltip.
        tooltipModel: Model<TooltipOption>,
        defaultHtml: string,
        params: TooltipCallbackDataParams | TooltipCallbackDataParams[],
        asyncTicket: string,
        x: number,
        y: number,
        positionExpr: TooltipOption['position'],
        el: ECElement,
        markupStyleCreator: TooltipMarkupStyleCreator
    ) {
        // Reset ticket
        this._ticket = '';

        if (!tooltipModel.get('showContent') || !tooltipModel.get('show')) {
            return;
        }

        const tooltipContent = this._tooltipContent;
        tooltipContent.setEnterable(tooltipModel.get('enterable'));

        const formatter = tooltipModel.get('formatter');
        positionExpr = positionExpr || tooltipModel.get('position');
        let html: string | HTMLElement | HTMLElement[] = defaultHtml;
        const nearPoint = this._getNearestPoint(
            [x, y],
            params,
            tooltipModel.get('trigger'),
            tooltipModel.get('borderColor')
        );
        const nearPointColor = nearPoint.color;

        if (formatter) {
            if (isString(formatter)) {
                const useUTC = tooltipModel.ecModel.get('useUTC');
                const params0 = isArray(params) ? params[0] : params;
                const isTimeAxis = params0 && params0.axisType && params0.axisType.indexOf('time') >= 0;
                html = formatter;
                if (isTimeAxis) {
                    html = timeFormat(params0.axisValue, html, useUTC);
                }
                html = formatTpl(html, params, true);
            }
            else if (isFunction(formatter)) {
                const callback = bind(function (cbTicket: string, html: string | HTMLElement | HTMLElement[]) {
                    if (cbTicket === this._ticket) {
                        tooltipContent.setContent(html, markupStyleCreator, tooltipModel, nearPointColor, positionExpr);
                        this._updatePosition(
                            tooltipModel, positionExpr, x, y, tooltipContent, params, el
                        );
                    }
                }, this);
                this._ticket = asyncTicket;
                html = formatter(params, asyncTicket, callback);
            }
            else {
                html = formatter;
            }
        }

        tooltipContent.setContent(html, markupStyleCreator, tooltipModel, nearPointColor, positionExpr);
        tooltipContent.show(tooltipModel, nearPointColor);
        this._updatePosition(
            tooltipModel, positionExpr, x, y, tooltipContent, params, el
        );

    }

    private _getNearestPoint(
        point: number[],
        tooltipDataParams: TooltipCallbackDataParams | TooltipCallbackDataParams[],
        trigger: TooltipOption['trigger'],
        borderColor: ZRColor
    ): {
        color: ZRColor;
    } {
        if (trigger === 'axis' || isArray(tooltipDataParams)) {
            return {
                color: borderColor || (this._renderMode === 'html' ? '#fff' : 'none')
            };
        }

        if (!isArray(tooltipDataParams)) {
            return {
                color: borderColor || tooltipDataParams.color || tooltipDataParams.borderColor
            };
        }
    }

    _updatePosition(
        tooltipModel: Model<TooltipOption>,
        positionExpr: TooltipOption['position'],
        x: number,  // Mouse x
        y: number,  // Mouse y
        content: TooltipHTMLContent | TooltipRichContent,
        params: TooltipCallbackDataParams | TooltipCallbackDataParams[],
        el?: Element
    ) {
        const viewWidth = this._api.getWidth();
        const viewHeight = this._api.getHeight();

        positionExpr = positionExpr || tooltipModel.get('position');

        const contentSize = content.getSize();
        let align = tooltipModel.get('align');
        let vAlign = tooltipModel.get('verticalAlign');
        const rect = el && el.getBoundingRect().clone();
        el && rect.applyTransform(el.transform);

        if (isFunction(positionExpr)) {
            // Callback of position can be an array or a string specify the position
            positionExpr = positionExpr([x, y], params, content.el, rect, {
                viewSize: [viewWidth, viewHeight],
                contentSize: contentSize.slice() as [number, number]
            });
        }

        if (isArray(positionExpr)) {
            x = parsePercent(positionExpr[0], viewWidth);
            y = parsePercent(positionExpr[1], viewHeight);
        }
        else if (isObject(positionExpr)) {
            const boxLayoutPosition = positionExpr as BoxLayoutOptionMixin;
            boxLayoutPosition.width = contentSize[0];
            boxLayoutPosition.height = contentSize[1];
            const layoutRect = getLayoutRect(
                boxLayoutPosition, { width: viewWidth, height: viewHeight }
            );
            x = layoutRect.x;
            y = layoutRect.y;
            align = null;
            // When positionExpr is left/top/right/bottom,
            // align and verticalAlign will not work.
            vAlign = null;
        }
        // Specify tooltip position by string 'top' 'bottom' 'left' 'right' around graphic element
        else if (isString(positionExpr) && el) {
            const pos = calcTooltipPosition(
                positionExpr, rect, contentSize, tooltipModel.get('borderWidth')
            );
            x = pos[0];
            y = pos[1];
        }
        else {
            const pos = refixTooltipPosition(
                x, y, content, viewWidth, viewHeight, align ? null : 20, vAlign ? null : 20
            );
            x = pos[0];
            y = pos[1];
        }

        align && (x -= isCenterAlign(align) ? contentSize[0] / 2 : align === 'right' ? contentSize[0] : 0);
        vAlign && (y -= isCenterAlign(vAlign) ? contentSize[1] / 2 : vAlign === 'bottom' ? contentSize[1] : 0);

        if (shouldTooltipConfine(tooltipModel)) {
            const pos = confineTooltipPosition(
                x, y, content, viewWidth, viewHeight
            );
            x = pos[0];
            y = pos[1];
        }

        content.moveTo(x, y);
    }

    // FIXME
    // Should we remove this but leave this to user?
    private _updateContentNotChangedOnAxis(
        dataByCoordSys: DataByCoordSys[],
        cbParamsList: TooltipCallbackDataParams[]
    ) {
        const lastCoordSys = this._lastDataByCoordSys;
        const lastCbParamsList = this._cbParamsList;
        let contentNotChanged = !!lastCoordSys
            && lastCoordSys.length === dataByCoordSys.length;

        contentNotChanged && each(lastCoordSys, (lastItemCoordSys, indexCoordSys) => {
            const lastDataByAxis = lastItemCoordSys.dataByAxis || [] as DataByAxis[];
            const thisItemCoordSys = dataByCoordSys[indexCoordSys] || {} as DataByCoordSys;
            const thisDataByAxis = thisItemCoordSys.dataByAxis || [] as DataByAxis[];
            contentNotChanged = contentNotChanged && lastDataByAxis.length === thisDataByAxis.length;

            contentNotChanged && each(lastDataByAxis, (lastItem, indexAxis) => {
                const thisItem = thisDataByAxis[indexAxis] || {} as DataByAxis;
                const lastIndices = lastItem.seriesDataIndices || [] as DataIndex[];
                const newIndices = thisItem.seriesDataIndices || [] as DataIndex[];

                contentNotChanged = contentNotChanged
                    && lastItem.value === thisItem.value
                    && lastItem.axisType === thisItem.axisType
                    && lastItem.axisId === thisItem.axisId
                    && lastIndices.length === newIndices.length;

                contentNotChanged && each(lastIndices, (lastIdxItem, j) => {
                    const newIdxItem = newIndices[j];
                    contentNotChanged = contentNotChanged
                        && lastIdxItem.seriesIndex === newIdxItem.seriesIndex
                        && lastIdxItem.dataIndex === newIdxItem.dataIndex;
                });

                // check is cbParams data value changed
                lastCbParamsList && each(lastItem.seriesDataIndices, (idxItem) => {
                    const seriesIdx = idxItem.seriesIndex;
                    const cbParams = cbParamsList[seriesIdx];
                    const lastCbParams = lastCbParamsList[seriesIdx];
                    if (cbParams && lastCbParams && lastCbParams.data !== cbParams.data) {
                        contentNotChanged = false;
                    }
                });
            });
        });

        this._lastDataByCoordSys = dataByCoordSys;
        this._cbParamsList = cbParamsList;

        return !!contentNotChanged;
    }

    private _hide(dispatchAction: ExtensionAPI['dispatchAction']) {
        // Do not directly hideLater here, because this behavior may be prevented
        // in dispatchAction when showTip is dispatched.

        // FIXME
        // duplicated hideTip if manuallyHideTip is called from dispatchAction.
        this._lastDataByCoordSys = null;
        dispatchAction({
            type: 'hideTip',
            from: this.uid
        });
    }

    dispose(ecModel: GlobalModel, api: ExtensionAPI) {
        if (env.node || !api.getDom()) {
            return;
        }
        clear(this, '_updatePosition');
        this._tooltipContent.dispose();
        globalListener.unregister('itemTooltip', api);
    }
}

type TooltipableOption = {
    tooltip?: CommonTooltipOption<unknown>;
};
type TooltipModelOptionCascade =
    Model<TooltipableOption> | CommonTooltipOption<unknown> | string;
/**
 * From top to bottom. (the last one should be globalTooltipModel);
 */
function buildTooltipModel(
    modelCascade: TooltipModelOptionCascade[],
    globalTooltipModel: TooltipModel,
    defaultTooltipOption?: CommonTooltipOption<unknown>
): Model<TooltipOption & ComponentItemTooltipOption<unknown>> {
    // Last is always tooltip model.
    const ecModel = globalTooltipModel.ecModel;
    let resultModel: Model<TooltipOption & ComponentItemTooltipOption<unknown>>;

    if (defaultTooltipOption) {
        resultModel = new Model(defaultTooltipOption, ecModel, ecModel);
        resultModel = new Model(globalTooltipModel.option, resultModel, ecModel);
    }
    else {
        resultModel = globalTooltipModel as Model<TooltipOption & ComponentItemTooltipOption<unknown>>;
    }

    for (let i = modelCascade.length - 1; i >= 0; i--) {
        let tooltipOpt = modelCascade[i];
        if (tooltipOpt) {
            if (tooltipOpt instanceof Model) {
                tooltipOpt = (tooltipOpt as Model<TooltipableOption>).get('tooltip', true);
            }
            // In each data item tooltip can be simply write:
            // {
            //  value: 10,
            //  tooltip: 'Something you need to know'
            // }
            if (isString(tooltipOpt)) {
                tooltipOpt = {
                    formatter: tooltipOpt
                };
            }
            if (tooltipOpt) {
                resultModel = new Model(tooltipOpt, resultModel, ecModel);
            }
        }
    }

    return resultModel as Model<TooltipOption & ComponentItemTooltipOption<unknown>>;
}

function makeDispatchAction(payload: ShowTipPayload | HideTipPayload, api: ExtensionAPI) {
    return payload.dispatchAction || bind(api.dispatchAction, api);
}

function refixTooltipPosition(
    x: number, y: number,
    content: TooltipHTMLContent | TooltipRichContent,
    viewWidth: number, viewHeight: number,
    gapH: number, gapV: number
) {
    const size = content.getSize();
    const width = size[0];
    const height = size[1];

    if (gapH != null) {
        // Add extra 2 pixels for this case:
        // At present the "values" in default tooltip are using CSS `float: right`.
        // When the right edge of the tooltip box is on the right side of the
        // viewport, the `float` layout might push the "values" to the second line.
        if (x + width + gapH + 2 > viewWidth) {
            x -= width + gapH;
        }
        else {
            x += gapH;
        }
    }
    if (gapV != null) {
        if (y + height + gapV > viewHeight) {
            y -= height + gapV;
        }
        else {
            y += gapV;
        }
    }
    return [x, y];
}

function confineTooltipPosition(
    x: number, y: number,
    content: TooltipHTMLContent | TooltipRichContent,
    viewWidth: number,
    viewHeight: number
): [number, number] {
    const size = content.getSize();
    const width = size[0];
    const height = size[1];

    x = Math.min(x + width, viewWidth) - width;
    y = Math.min(y + height, viewHeight) - height;
    x = Math.max(x, 0);
    y = Math.max(y, 0);

    return [x, y];
}

function calcTooltipPosition(
    position: TooltipOption['position'],
    rect: ZRRectLike,
    contentSize: number[],
    borderWidth: number
): [number, number] {
    const domWidth = contentSize[0];
    const domHeight = contentSize[1];
    const offset = Math.ceil(Math.SQRT2 * borderWidth) + 8;
    let x = 0;
    let y = 0;
    const rectWidth = rect.width;
    const rectHeight = rect.height;
    switch (position) {
        case 'inside':
            x = rect.x + rectWidth / 2 - domWidth / 2;
            y = rect.y + rectHeight / 2 - domHeight / 2;
            break;
        case 'top':
            x = rect.x + rectWidth / 2 - domWidth / 2;
            y = rect.y - domHeight - offset;
            break;
        case 'bottom':
            x = rect.x + rectWidth / 2 - domWidth / 2;
            y = rect.y + rectHeight + offset;
            break;
        case 'left':
            x = rect.x - domWidth - offset;
            y = rect.y + rectHeight / 2 - domHeight / 2;
            break;
        case 'right':
            x = rect.x + rectWidth + offset;
            y = rect.y + rectHeight / 2 - domHeight / 2;
    }
    return [x, y];
}

function isCenterAlign(align: HorizontalAlign | VerticalAlign) {
    return align === 'center' || align === 'middle';
}

/**
 * Find target component by payload like:
 * ```js
 * { legendId: 'some_id', name: 'xxx' }
 * { toolboxIndex: 1, name: 'xxx' }
 * { geoName: 'some_name', name: 'xxx' }
 * ```
 * PENDING: at present only
 *
 * If not found, return null/undefined.
 */
function findComponentReference(
    payload: ShowTipPayload,
    ecModel: GlobalModel,
    api: ExtensionAPI
): {
    componentMainType: ComponentMainType;
    componentIndex: number;
    el: ECElement;
} {
    const { queryOptionMap } = preParseFinder(payload);
    const componentMainType = queryOptionMap.keys()[0];
    if (!componentMainType || componentMainType === 'series') {
        return;
    }

    const queryResult = queryReferringComponents(
        ecModel,
        componentMainType,
        queryOptionMap.get(componentMainType),
        { useDefault: false, enableAll: false, enableNone: false }
    );
    const model = queryResult.models[0];
    if (!model) {
        return;
    }

    const view = api.getViewOfComponentModel(model);
    let el: ECElement;
    view.group.traverse((subEl: ECElement) => {
        const tooltipConfig = getECData(subEl).tooltipConfig;
        if (tooltipConfig && tooltipConfig.name === payload.name) {
            el = subEl;
            return true; // stop
        }
    });

    if (el) {
        return {
            componentMainType,
            componentIndex: model.componentIndex,
            el
        };
    }
}

export default TooltipView;
