/*
* 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 { each, createHashMap, merge, HashMap, assert } from 'zrender/src/core/util';
import AxisProxy from './AxisProxy';
import ComponentModel from '../../model/Component';
import {
    LayoutOrient,
    ComponentOption,
    LabelOption
} from '../../util/types';
import Model from '../../model/Model';
import GlobalModel from '../../model/Global';
import { AxisBaseModel } from '../../coord/AxisBaseModel';
import {
    getAxisMainType, DATA_ZOOM_AXIS_DIMENSIONS, DataZoomAxisDimension
} from './helper';
import SingleAxisModel from '../../coord/single/AxisModel';
import { MULTIPLE_REFERRING, SINGLE_REFERRING } from '../../util/model';


export interface DataZoomOption extends ComponentOption {

    mainType?: 'dataZoom'

    /**
     * Default auto by axisIndex
     */
    orient?: LayoutOrient

    /**
     * Default the first horizontal category axis.
     */
    xAxisIndex?: number | number[]
    xAxisId?: string | string[]

    /**
     * Default the first vertical category axis.
     */
    yAxisIndex?: number | number[]
    yAxisId?: string | string[]

    radiusAxisIndex?: number | number[]
    radiusAxisId?: string | string[]
    angleAxisIndex?: number | number[]
    angleAxisId?: string | string[]

    singleAxisIndex?: number | number[]
    singleAxisId?: string | string[]

    /**
     * Possible values: 'filter' or 'empty' or 'weakFilter'.
     * 'filter': data items which are out of window will be removed. This option is
     *         applicable when filtering outliers. For each data item, it will be
     *         filtered if one of the relevant dimensions is out of the window.
     * 'weakFilter': data items which are out of window will be removed. This option
     *         is applicable when filtering outliers. For each data item, it will be
     *         filtered only if all  of the relevant dimensions are out of the same
     *         side of the window.
     * 'empty': data items which are out of window will be set to empty.
     *         This option is applicable when user should not neglect
     *         that there are some data items out of window.
     * 'none': Do not filter.
     * Taking line chart as an example, line will be broken in
     * the filtered points when filterModel is set to 'empty', but
     * be connected when set to 'filter'.
     */
    filterMode?: 'filter' | 'weakFilter' | 'empty' | 'none'

    /**
     * Dispatch action by the fixed rate, avoid frequency.
     * default 100. Do not throttle when use null/undefined.
     * If animation === true and animationDurationUpdate > 0,
     * default value is 100, otherwise 20.
     */
    throttle?: number | null | undefined
    /**
     * Start percent. 0 ~ 100
     */
    start?: number
    /**
     * End percent. 0 ~ 100
     */
    end?: number
    /**
     * Start value. If startValue specified, start is ignored
     */
    startValue?: number | string | Date
    /**
     * End value. If endValue specified, end is ignored.
     */
    endValue?: number | string | Date
    /**
     * Min span percent, 0 - 100
     * The range of dataZoom can not be smaller than that.
     */
    minSpan?: number
    /**
     * Max span percent, 0 - 100
     * The range of dataZoom can not be larger than that.
     */
    maxSpan?: number

    minValueSpan?: number

    maxValueSpan?: number

    rangeMode?: ['value' | 'percent', 'value' | 'percent']

    realtime?: boolean

    // Available when type is slider
    textStyle?: LabelOption
}

type RangeOption = Pick<DataZoomOption, 'start' | 'end' | 'startValue' | 'endValue'>;

export type DataZoomExtendedAxisBaseModel = AxisBaseModel & {
    __dzAxisProxy: AxisProxy
};

class DataZoomAxisInfo {
    indexList: number[] = [];
    indexMap: boolean[] = [];

    add(axisCmptIdx: number) {
        // Remove duplication.
        if (!this.indexMap[axisCmptIdx]) {
            this.indexList.push(axisCmptIdx);
            this.indexMap[axisCmptIdx] = true;
        }
    }
}
export type DataZoomTargetAxisInfoMap = HashMap<DataZoomAxisInfo, DataZoomAxisDimension>;

class DataZoomModel<Opts extends DataZoomOption = DataZoomOption> extends ComponentModel<Opts> {
    static type = 'dataZoom';
    type = DataZoomModel.type;

    static dependencies = [
        'xAxis', 'yAxis', 'radiusAxis', 'angleAxis', 'singleAxis', 'series', 'toolbox'
    ];


    static defaultOption: DataZoomOption = {
        zlevel: 0,
        z: 4,                   // Higher than normal component (z: 2).

        filterMode: 'filter',

        start: 0,
        end: 100
    };

    private _autoThrottle = true;

    private _orient: LayoutOrient;

    private _targetAxisInfoMap: DataZoomTargetAxisInfoMap;

    private _noTarget: boolean = true;

    /**
     * It is `[rangeModeForMin, rangeModeForMax]`.
     * The optional values for `rangeMode`:
     * + `'value'` mode: the axis extent will always be determined by
     *     `dataZoom.startValue` and `dataZoom.endValue`, despite
     *     how data like and how `axis.min` and `axis.max` are.
     * + `'percent'` mode: `100` represents 100% of the `[dMin, dMax]`,
     *     where `dMin` is `axis.min` if `axis.min` specified, otherwise `data.extent[0]`,
     *     and `dMax` is `axis.max` if `axis.max` specified, otherwise `data.extent[1]`.
     *     Axis extent will be determined by the result of the percent of `[dMin, dMax]`.
     *
     * For example, when users are using dynamic data (update data periodically via `setOption`),
     * if in `'value`' mode, the window will be kept in a fixed value range despite how
     * data are appended, while if in `'percent'` mode, whe window range will be changed alone with
     * the appended data (suppose `axis.min` and `axis.max` are not specified).
     */
    private _rangePropMode: DataZoomOption['rangeMode'] = ['percent', 'percent'];

    /**
     * @readonly
     */
    settledOption: Opts;

    init(option: Opts, parentModel: Model, ecModel: GlobalModel): void {

        const inputRawOption = retrieveRawOption(option);

        /**
         * Suppose a "main process" start at the point that model prepared (that is,
         * model initialized or merged or method called in `action`).
         * We should keep the `main process` idempotent, that is, given a set of values
         * on `option`, we get the same result.
         *
         * But sometimes, values on `option` will be updated for providing users
         * a "final calculated value" (`dataZoomProcessor` will do that). Those value
         * should not be the base/input of the `main process`.
         *
         * So in that case we should save and keep the input of the `main process`
         * separately, called `settledOption`.
         *
         * For example, consider the case:
         * (Step_1) brush zoom the grid by `toolbox.dataZoom`,
         *     where the original input `option.startValue`, `option.endValue` are earsed by
         *     calculated value.
         * (Step)2) click the legend to hide and show a series,
         *     where the new range is calculated by the earsed `startValue` and `endValue`,
         *     which brings incorrect result.
         */
        this.settledOption = inputRawOption;

        this.mergeDefaultAndTheme(option, ecModel);

        this._doInit(inputRawOption);
    }

    mergeOption(newOption: Opts): void {
        const inputRawOption = retrieveRawOption(newOption);

        //FIX #2591
        merge(this.option, newOption, true);
        merge(this.settledOption, inputRawOption, true);

        this._doInit(inputRawOption);
    }

    private _doInit(inputRawOption: Opts): void {
        const thisOption = this.option;

        // if (!env.canvasSupported) {
        //     thisOption.realtime = false;
        // }

        this._setDefaultThrottle(inputRawOption);

        this._updateRangeUse(inputRawOption);

        const settledOption = this.settledOption;
        each([['start', 'startValue'], ['end', 'endValue']] as const, function (names, index) {
            // start/end has higher priority over startValue/endValue if they
            // both set, but we should make chart.setOption({endValue: 1000})
            // effective, rather than chart.setOption({endValue: 1000, end: null}).
            if (this._rangePropMode[index] === 'value') {
                thisOption[names[0]] = settledOption[names[0]] = null;
            }
            // Otherwise do nothing and use the merge result.
        }, this);

        this._resetTarget();
    }

    private _resetTarget() {
        const optionOrient = this.get('orient', true);
        const targetAxisIndexMap = this._targetAxisInfoMap = createHashMap<DataZoomAxisInfo, DataZoomAxisDimension>();

        const hasAxisSpecified = this._fillSpecifiedTargetAxis(targetAxisIndexMap);

        if (hasAxisSpecified) {
            this._orient = optionOrient || this._makeAutoOrientByTargetAxis();
        }
        else {
            this._orient = optionOrient || 'horizontal';
            this._fillAutoTargetAxisByOrient(targetAxisIndexMap, this._orient);
        }

        this._noTarget = true;
        targetAxisIndexMap.each(function (axisInfo) {
            if (axisInfo.indexList.length) {
                this._noTarget = false;
            }
        }, this);
    }

    private _fillSpecifiedTargetAxis(targetAxisIndexMap: DataZoomTargetAxisInfoMap): boolean {
        let hasAxisSpecified = false;

        each(DATA_ZOOM_AXIS_DIMENSIONS, function (axisDim) {
            const refering = this.getReferringComponents(getAxisMainType(axisDim), MULTIPLE_REFERRING);
            // When user set axisIndex as a empty array, we think that user specify axisIndex
            // but do not want use auto mode. Because empty array may be encountered when
            // some error occured.
            if (!refering.specified) {
                return;
            }
            hasAxisSpecified = true;
            const axisInfo = new DataZoomAxisInfo();
            each(refering.models, function (axisModel) {
                axisInfo.add(axisModel.componentIndex);
            });
            targetAxisIndexMap.set(axisDim, axisInfo);
        }, this);

        return hasAxisSpecified;
    }

    private _fillAutoTargetAxisByOrient(targetAxisIndexMap: DataZoomTargetAxisInfoMap, orient: LayoutOrient): void {
        const ecModel = this.ecModel;
        let needAuto = true;

        // Find axis that parallel to dataZoom as default.
        if (needAuto) {
            const axisDim = orient === 'vertical' ? 'y' : 'x';
            const axisModels = ecModel.findComponents({ mainType: axisDim + 'Axis' });
            setParallelAxis(axisModels, axisDim);
        }
        // Find axis that parallel to dataZoom as default.
        if (needAuto) {
            const axisModels = ecModel.findComponents({
                mainType: 'singleAxis',
                filter: (axisModel: SingleAxisModel) => axisModel.get('orient', true) === orient
            });
            setParallelAxis(axisModels, 'single');
        }

        function setParallelAxis(axisModels: ComponentModel[], axisDim: DataZoomAxisDimension): void {
            // At least use the first parallel axis as the target axis.
            const axisModel = axisModels[0];
            if (!axisModel) {
                return;
            }

            const axisInfo = new DataZoomAxisInfo();
            axisInfo.add(axisModel.componentIndex);
            targetAxisIndexMap.set(axisDim, axisInfo);
            needAuto = false;

            // Find parallel axes in the same grid.
            if (axisDim === 'x' || axisDim === 'y') {
                const gridModel = axisModel.getReferringComponents('grid', SINGLE_REFERRING).models[0];
                gridModel && each(axisModels, function (axModel) {
                    if (axisModel.componentIndex !== axModel.componentIndex
                        && gridModel === axModel.getReferringComponents('grid', SINGLE_REFERRING).models[0]
                    ) {
                        axisInfo.add(axModel.componentIndex);
                    }
                });
            }
        }

        if (needAuto) {
            // If no parallel axis, find the first category axis as default. (Also consider polar).
            each(DATA_ZOOM_AXIS_DIMENSIONS, function (axisDim) {
                if (!needAuto) {
                    return;
                }
                const axisModels = ecModel.findComponents({
                    mainType: getAxisMainType(axisDim),
                    filter: (axisModel: SingleAxisModel) => axisModel.get('type', true) === 'category'
                });
                if (axisModels[0]) {
                    const axisInfo = new DataZoomAxisInfo();
                    axisInfo.add(axisModels[0].componentIndex);
                    targetAxisIndexMap.set(axisDim, axisInfo);
                    needAuto = false;
                }
            }, this);
        }
    }

    private _makeAutoOrientByTargetAxis(): LayoutOrient {
        let dim: string;

        // Find the first axis
        this.eachTargetAxis(function (axisDim) {
            !dim && (dim = axisDim);
        }, this);

        return dim === 'y' ? 'vertical' : 'horizontal';
    }

    private _setDefaultThrottle(inputRawOption: DataZoomOption): void {
        // When first time user set throttle, auto throttle ends.
        if (inputRawOption.hasOwnProperty('throttle')) {
            this._autoThrottle = false;
        }
        if (this._autoThrottle) {
            const globalOption = this.ecModel.option;
            this.option.throttle = (
                globalOption.animation && globalOption.animationDurationUpdate > 0
            ) ? 100 : 20;
        }
    }

    private _updateRangeUse(inputRawOption: RangeOption): void {
        const rangePropMode = this._rangePropMode;
        const rangeModeInOption = this.get('rangeMode');

        each([['start', 'startValue'], ['end', 'endValue']] as const, function (names, index) {
            const percentSpecified = inputRawOption[names[0]] != null;
            const valueSpecified = inputRawOption[names[1]] != null;
            if (percentSpecified && !valueSpecified) {
                rangePropMode[index] = 'percent';
            }
            else if (!percentSpecified && valueSpecified) {
                rangePropMode[index] = 'value';
            }
            else if (rangeModeInOption) {
                rangePropMode[index] = rangeModeInOption[index];
            }
            else if (percentSpecified) { // percentSpecified && valueSpecified
                rangePropMode[index] = 'percent';
            }
            // else remain its original setting.
        });
    }

    noTarget(): boolean {
        return this._noTarget;
    }

    getFirstTargetAxisModel(): AxisBaseModel {
        let firstAxisModel: AxisBaseModel;
        this.eachTargetAxis(function (axisDim, axisIndex) {
            if (firstAxisModel == null) {
                firstAxisModel = this.ecModel.getComponent(
                    getAxisMainType(axisDim), axisIndex
                ) as AxisBaseModel;
            }
        }, this);

        return firstAxisModel;
    }

    /**
     * @param {Function} callback param: axisModel, dimNames, axisIndex, dataZoomModel, ecModel
     */
    eachTargetAxis<Ctx>(
        callback: (
            this: Ctx,
            axisDim: DataZoomAxisDimension,
            axisIndex: number
        ) => void,
        context?: Ctx
    ): void {
        this._targetAxisInfoMap.each(function (axisInfo, axisDim) {
            each(axisInfo.indexList, function (axisIndex) {
                callback.call(context, axisDim, axisIndex);
            });
        });
    }

    /**
     * @return If not found, return null/undefined.
     */
    getAxisProxy(axisDim: DataZoomAxisDimension, axisIndex: number): AxisProxy {
        const axisModel = this.getAxisModel(axisDim, axisIndex);
        if (axisModel) {
            return (axisModel as DataZoomExtendedAxisBaseModel).__dzAxisProxy;
        }
    }

    /**
     * @return If not found, return null/undefined.
     */
    getAxisModel(axisDim: DataZoomAxisDimension, axisIndex: number): AxisBaseModel {
        if (__DEV__) {
            assert(axisDim && axisIndex != null);
        }
        const axisInfo = this._targetAxisInfoMap.get(axisDim);
        if (axisInfo && axisInfo.indexMap[axisIndex]) {
            return this.ecModel.getComponent(getAxisMainType(axisDim), axisIndex) as AxisBaseModel;
        }
    }

    /**
     * If not specified, set to undefined.
     */
    setRawRange(opt: RangeOption): void {
        const thisOption = this.option;
        const settledOption = this.settledOption;
        each([['start', 'startValue'], ['end', 'endValue']] as const, function (names) {
            // Consider the pair <start, startValue>:
            // If one has value and the other one is `null/undefined`, we both set them
            // to `settledOption`. This strategy enables the feature to clear the original
            // value in `settledOption` to `null/undefined`.
            // But if both of them are `null/undefined`, we do not set them to `settledOption`
            // and keep `settledOption` with the original value. This strategy enables users to
            // only set <end or endValue> but not set <start or startValue> when calling
            // `dispatchAction`.
            // The pair <end, endValue> is treated in the same way.
            if (opt[names[0]] != null || opt[names[1]] != null) {
                thisOption[names[0]] = settledOption[names[0]] = opt[names[0]];
                thisOption[names[1]] = settledOption[names[1]] = opt[names[1]];
            }
        }, this);

        this._updateRangeUse(opt);
    }

    setCalculatedRange(opt: RangeOption): void {
        const option = this.option;
        each(['start', 'startValue', 'end', 'endValue'] as const, function (name) {
            (option as any)[name] = opt[name];
        });
    }

    getPercentRange(): number[] {
        const axisProxy = this.findRepresentativeAxisProxy();
        if (axisProxy) {
            return axisProxy.getDataPercentWindow();
        }
    }

    /**
     * For example, chart.getModel().getComponent('dataZoom').getValueRange('y', 0);
     *
     * @return [startValue, endValue] value can only be '-' or finite number.
     */
    getValueRange(axisDim: DataZoomAxisDimension, axisIndex: number): number[] {
        if (axisDim == null && axisIndex == null) {
            const axisProxy = this.findRepresentativeAxisProxy();
            if (axisProxy) {
                return axisProxy.getDataValueWindow();
            }
        }
        else {
            return this.getAxisProxy(axisDim, axisIndex).getDataValueWindow();
        }
    }

    /**
     * @param axisModel If axisModel given, find axisProxy
     *      corresponding to the axisModel
     */
    findRepresentativeAxisProxy(axisModel?: AxisBaseModel): AxisProxy {
        if (axisModel) {
            return (axisModel as DataZoomExtendedAxisBaseModel).__dzAxisProxy;
        }

        // Find the first hosted axisProxy
        let firstProxy;
        const axisDimList = this._targetAxisInfoMap.keys();
        for (let i = 0; i < axisDimList.length; i++) {
            const axisDim = axisDimList[i];
            const axisInfo = this._targetAxisInfoMap.get(axisDim);
            for (let j = 0; j < axisInfo.indexList.length; j++) {
                const proxy = this.getAxisProxy(axisDim, axisInfo.indexList[j]);
                if (proxy.hostedBy(this)) {
                    return proxy;
                }
                if (!firstProxy) {
                    firstProxy = proxy;
                }
            }
        }

        // If no hosted proxy found, still need to return a proxy.
        // This case always happens in toolbox dataZoom, where axes are all hosted by
        // other dataZooms.
        return firstProxy;
    }

    getRangePropMode(): DataZoomModel['_rangePropMode'] {
        return this._rangePropMode.slice() as DataZoomModel['_rangePropMode'];
    }

    getOrient(): LayoutOrient {
        if (__DEV__) {
            // Should not be called before initialized.
            assert(this._orient);
        }
        return this._orient;
    }

}
/**
 * Retrieve the those raw params from option, which will be cached separately.
 * becasue they will be overwritten by normalized/calculated values in the main
 * process.
 */
function retrieveRawOption<T extends DataZoomOption>(option: T) {
    const ret = {} as T;
    each(
        ['start', 'end', 'startValue', 'endValue', 'throttle'] as const,
        function (name) {
            option.hasOwnProperty(name) && ((ret as any)[name] = option[name]);
        }
    );
    return ret;
}

export default DataZoomModel;