/*
* 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 {clone, defaults, each, map} from 'zrender/src/core/util';
import {
    asc, getAcceptableTickPrecision, linearMap, mathAbs, mathCeil, mathFloor, mathMax, mathMin, round
} from '../../util/number';
import sliderMove from '../helper/sliderMove';
import GlobalModel from '../../model/Global';
import SeriesModel from '../../model/Series';
import ExtensionAPI from '../../core/ExtensionAPI';
import { Dictionary, NullUndefined } from '../../util/types';
// TODO Polar?
import DataZoomModel from './DataZoomModel';
import { AxisBaseModel } from '../../coord/AxisBaseModel';
import { getAxisMainType, isCoordSupported, DataZoomAxisDimension } from './helper';
import { ensureExtentAscSimply, SINGLE_REFERRING } from '../../util/model';
import { isOrdinalScale, isTimeScale } from '../../scale/helper';
import {
    AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM, scaleRawExtentInfoCreate,
    ScaleRawExtentResultForZoom,
} from '../../coord/scaleRawExtentInfo';


interface MinMaxSpan {
    minSpan: number
    maxSpan: number
    minValueSpan: number
    maxValueSpan: number
}

export interface AxisProxyWindow {
    // NOTE: May include non-effective portion.
    value: ScaleRawExtentResultForZoom;
    percent: number[];
    // Percent invert from "value window", which may be slightly different from "percent window" due to some
    // handling such as rounding. The difference may be magnified in cases like "alignTicks", so we use
    // `percentInverted` in these cases.
    // But we retain the original input percent in `percent` whenever possible, since they have been used in views.
    percentInverted: number[];
    valuePrecision: number;
}

/**
 * Operate single axis.
 * One axis can only operated by one axis operator.
 * Different dataZoomModels may be defined to operate the same axis.
 * (i.e. 'inside' data zoom and 'slider' data zoom components)
 * So dataZoomModels share one axisProxy in that case.
 */
class AxisProxy {

    ecModel: GlobalModel;

    // NOTICE: The lifetime of `AxisProxy` instance is different from `Axis` instance.
    // It is recreated in each run of "ec prepare".

    private _dimName: DataZoomAxisDimension;
    private _axisIndex: number;

    private _window: AxisProxyWindow;

    private _extent: ScaleRawExtentResultForZoom;

    private _minMaxSpan: MinMaxSpan;

    /**
     * The host `dataZoom` model. An axis may be controlled by multiple `dataZoom`s,
     * but only the first declared `dataZoom` is the host.
     */
    private _dataZoomModel: DataZoomModel;

    constructor(
        dimName: DataZoomAxisDimension,
        axisIndex: number,
        dataZoomModel: DataZoomModel,
        ecModel: GlobalModel
    ) {
        this._dimName = dimName;

        this._axisIndex = axisIndex;

        this.ecModel = ecModel;

        this._dataZoomModel = dataZoomModel;

        // /**
        //  * @readOnly
        //  * @private
        //  */
        // this.hasSeriesStacked;
    }

    /**
     * Whether the axisProxy is hosted by dataZoomModel.
     */
    hostedBy(dataZoomModel: DataZoomModel): boolean {
        return this._dataZoomModel === dataZoomModel;
    }

    /**
     * @return `getWindow().value` can only have NaN or finite value.
     */
    getWindow(): AxisProxyWindow {
        return clone(this._window);
    }

    getTargetSeriesModels() {
        const seriesModels: SeriesModel[] = [];

        this.ecModel.eachSeries(function (seriesModel) {
            if (isCoordSupported(seriesModel)) {
                const axisMainType = getAxisMainType(this._dimName);
                const axisModel = seriesModel.getReferringComponents(axisMainType, SINGLE_REFERRING).models[0];
                if (axisModel && this._axisIndex === axisModel.componentIndex) {
                    seriesModels.push(seriesModel);
                }
            }
        }, this);

        return seriesModels;
    }

    getAxisModel(): AxisBaseModel {
        return this.ecModel.getComponent(this._dimName + 'Axis', this._axisIndex) as AxisBaseModel;
    }

    getMinMaxSpan() {
        return clone(this._minMaxSpan);
    }

    /**
     * [CAVEAT] Keep this method pure, so that it can be called multiple times.
     */
    calculateDataWindow(
        opt: {
            start?: number // percent, 0 ~ 100
            end?: number // percent, 0 ~ 100
            startValue?: number | string | Date
            endValue?: number | string | Date
        }
    ): AxisProxyWindow {
        const dataExtent = this._extent;
        const axis = this.getAxisModel().axis;
        const scale = axis.scale;
        const dataZoomModel = this._dataZoomModel;
        const rangePropMode = dataZoomModel.getRangePropMode();
        const percentExtent = [0, 100];
        const percentWindow = [] as unknown as [number, number];
        const valueWindow = [] as unknown as [number, number];
        let hasPropModeValue;
        const needRound = [false, false];

        // NOTE:
        //  The current percentage base calculation strategy:
        //    - If the window boundary is NOT at 0% or 100%, boundary values are derived from the raw extent
        //      (series data + axis.min/max; see `ScaleRawExtentInfo['makeForZoom']`). Any subsequent "nice"
        //      expansion are excluded.
        //    - If the window boundary is at 0% or 100%, the "nice"-expanded portion is included.
        //  Pros:
        //    - The effect may be preferable when users intend to quickly narrow down to data details,
        //      especially when "nice strategy" excessively expands the extent.
        //    - It simplifies the logic, otherwise, "nice strategy" would need to be applied twice (full window
        //      + current window).
        //  Cons:
        //    - This strategy causes jitter when switching dataZoom to/from 0%/100% (though generally acceptable).

        each(['start', 'end'] as const, function (prop, idx) {
            let boundPercent = opt[prop];
            let boundValue = opt[prop + 'Value' as 'startValue' | 'endValue'];

            // NOTE: dataZoom is based either on `percentProp` ('start', 'end') or
            // on `valueProp` ('startValue', 'endValue').
            // The former one is suitable for cases that a dataZoom component controls multiple
            // axes with different unit or extent, and the latter one is suitable for accurate
            // zoom by pixel (e.g., in dataZoomSelect).
            // we use `getRangePropMode()` to mark which prop is used. `rangePropMode` is updated
            // only when setOption or dispatchAction, otherwise it remains its original value.
            // (Why not only record `percentProp` and always map to `valueProp`? Because
            // the map `valueProp` -> `percentProp` -> `valueProp` probably not the original
            // `valueProp`. consider two axes constrolled by one dataZoom. They have different
            // data extent. All of values that are overflow the `dataExtent` will be calculated
            // to percent '100%').

            if (rangePropMode[idx] === 'percent') {
                boundPercent == null && (boundPercent = percentExtent[idx]);
                boundValue = linearMap(boundPercent, percentExtent, dataExtent);
                needRound[idx] = true;
            }
            else {
                hasPropModeValue = true;
                // NOTE: `scale.parse` can also round input for 'time' or 'ordinal' scale.
                if (boundValue == null) {
                    boundValue = dataExtent[idx];
                }
                else {
                    // Need to parse user inputs from ec option or action param.
                    boundValue = scale.parse(boundValue);
                    if (scale.sanitize) {
                        boundValue = scale.sanitize(boundValue, dataExtent);
                    }
                }
                // Calculating `percent` from `value` may be not accurate, because
                // This calculation can not be inverted, because all of values that
                // are overflow the `dataExtent` will be calculated to percent '100%'
                boundPercent = linearMap(boundValue, dataExtent, percentExtent);
            }

            // fallback to extent start/end when parsed value or percent is invalid
            valueWindow[idx] = boundValue == null || isNaN(boundValue)
                ? dataExtent[idx]
                : boundValue;
            percentWindow[idx] = boundPercent == null || isNaN(boundPercent)
                ? percentExtent[idx]
                : boundPercent;
        });

        // Historical behavior - enable switching.
        asc(valueWindow);
        asc(percentWindow);

        // The windows specified from `dispatchAction` or `setOption` may:
        //  (1) be out of the extent, or
        //  (2) do not comply with `minSpan/maxSpan`, `minValueSpan/maxValueSpan`.
        // So we clamp them here.
        // But we don't restrict window by `zoomLock` here, because we see `zoomLock` just as a
        // interaction constraint, where API is able to initialize/modify the window size even
        // though `zoomLock` specified.
        // PENDING: For historical reason, the option design is partially incompatible:
        //  If `option.start` and `option.endValue` are specified, and when we choose whether
        //  `min/maxValueSpan` or `minSpan/maxSpan` is applied, neither one is intuitive.
        //  (Currently using `minValueSpan/maxValueSpan`.)
        const spans = this._minMaxSpan;
        hasPropModeValue
            ? restrictSet(valueWindow, percentWindow, dataExtent, percentExtent, false)
            : restrictSet(percentWindow, valueWindow, percentExtent, dataExtent, true);

        function restrictSet(
            fromWindow: number[],
            toWindow: number[],
            fromExtent: number[],
            toExtent: number[],
            toValue: boolean
        ) {
            const suffix = toValue ? 'Span' : 'ValueSpan';
            sliderMove(
                0, fromWindow, fromExtent, 'all',
                spans['min' + suffix as 'minSpan' | 'minValueSpan'],
                spans['max' + suffix as 'maxSpan' | 'maxValueSpan']
            );
            for (let i = 0; i < 2; i++) {
                toWindow[i] = linearMap(fromWindow[i], fromExtent, toExtent, true);
                if (toValue) {
                    toWindow[i] = toWindow[i];
                    needRound[i] = true;
                }
            }
            ensureExtentAscSimply(toWindow);
        }

        // - In 'time' and 'ordinal' scale, rounding by 0 is required.
        // - In 'interval' and 'log' scale, we round values for acceptable display with acceptable accuracy loose.
        //  "Values" can be rounded only if they are generated from `percent`, since user-specified "value"
        //  should be respected, and `DataZoomSelect` already performs its own rounding.
        // - Currently we only round "value" but not "percent", since there is no need so far.
        // - MEMO: See also #3228 and commit a89fd0d7f1833ecf08a4a5b7ecf651b4a0d8da41
        // - PENDING: The rounding result may slightly overflow the restriction from `min/maxSpan`,
        //  but it is acceptable so far.
        const isScaleOrdinalOrTime = isOrdinalScale(scale) || isTimeScale(scale);
        // Typically pxExtent has been ready in coordSys create. (See `create` of `Grid.ts`)
        const pxExtent = axis.getExtent();
        // NOTICE: this pxSpan may be not accurate yet due to "outerBounds" logic, but acceptable.
        const pxSpan = mathAbs(pxExtent[1] - pxExtent[0]);
        const precision = isScaleOrdinalOrTime
            ? 0
            // NOTICE: We deliberately do not allow specifying this precision by users, until real requirements
            // occur. Otherwise, unnecessary complexity and bad case may be introduced. A small precision may
            // cause the rounded ends overflow the expected min/max significantly. And this precision effectively
            // determines the size of a roaming step, and a big step would likely constantly cut through series
            // shapes in an unexpected place and cause visual artifacts (e.g., for bar series). Although
            // theroetically that defect can be resolved by introducing extra spaces between axis min/max tick
            // and axis boundary (see `SCALE_EXTENT_KIND_MAPPING`), it's complicated and unnecessary.
            : getAcceptableTickPrecision(valueWindow, pxSpan, 0.5);
        each([[0, mathCeil], [1, mathFloor]] as const, function ([idx, ceilOrFloor]) {
            if (!needRound[idx] || !isFinite(precision)) {
                return;
            }
            valueWindow[idx] = round(valueWindow[idx], precision);
            valueWindow[idx] = mathMin(dataExtent[1], mathMax(dataExtent[0], valueWindow[idx])); // Clamp.
            if (percentWindow[idx] === percentExtent[idx]) {
                // When `percent` is 0 or 100, `value` must be `dataExtent[0]` or `dataExtent[1]`
                // regardless of the calculated precision.
                // NOTE: `percentWindow` is never over [0, 100] at this moment.
                valueWindow[idx] = dataExtent[idx];
                if (isScaleOrdinalOrTime) {
                    // In case that dataExtent[idx] is not an integer (may occur since it comes from user input)
                    valueWindow[idx] = ceilOrFloor(valueWindow[idx]);
                }
            }
        });
        ensureExtentAscSimply(valueWindow);

        const percentInvertedWindow = [
            linearMap(valueWindow[0], dataExtent, percentExtent, true),
            linearMap(valueWindow[1], dataExtent, percentExtent, true),
        ] as [number, number];
        ensureExtentAscSimply(percentInvertedWindow);

        return {
            value: valueWindow,
            percent: percentWindow,
            percentInverted: percentInvertedWindow,
            valuePrecision: precision,
        };
    }

    /**
     * Notice: reset should not be called before series.restoreData() is called,
     * so it is recommended to be called in "process stage" but not "model init
     * stage".
     */
    reset(dataZoomModel: DataZoomModel, alignToPercentInverted: number[] | NullUndefined) {
        if (!this.hostedBy(dataZoomModel)) {
            return;
        }

        // It is important to get "consistent" extent when more then one axes is
        // controlled by a `dataZoom`, otherwise those axes will not be synchronized
        // when zooming. But it is difficult to know what is "consistent", considering
        // axes have different type or even different meanings (For example, two
        // time axes are used to compare data of the same date in different years).
        // So basically dataZoom just obtains extent by series.data (in category axis
        // extent can be obtained from axis.data).
        // Nevertheless, user can set min/max/scale on axes to make extent of axes
        // consistent.
        const axis = this.getAxisModel().axis;
        scaleRawExtentInfoCreate(axis, AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM);

        const rawExtentInfo = axis.scale.rawExtentInfo;
        this._extent = rawExtentInfo.makeNoZoom();

        // `calculateDataWindow` uses min/maxSpan.
        this._updateMinMaxSpan();

        let opt = dataZoomModel.settledOption;
        if (alignToPercentInverted) {
            opt = defaults({
                start: alignToPercentInverted[0],
                end: alignToPercentInverted[1],
            }, opt);
        }
        const {percent, value} = this._window = this.calculateDataWindow(opt);

        if (percent[0] !== 0) {
            rawExtentInfo.setZoomMM(0, value[0]);
        }
        if (percent[1] !== 100) {
            rawExtentInfo.setZoomMM(1, value[1]);
        }
    }

    filterData(dataZoomModel: DataZoomModel, api: ExtensionAPI) {
        if (!this.hostedBy(dataZoomModel)) {
            return;
        }

        const axisDim = this._dimName;
        const seriesModels = this.getTargetSeriesModels();
        const filterMode = dataZoomModel.get('filterMode');
        const valueWindow = this._window.value;

        if (filterMode === 'none') {
            return;
        }

        // FIXME
        // Toolbox may has dataZoom injected. And if there are stacked bar chart
        // with NaN data, NaN will be filtered and stack will be wrong.
        // So we need to force the mode to be set empty.
        // In fact, it is not a big deal that do not support filterMode-'filter'
        // when using toolbox#dataZoom, util tooltip#dataZoom support "single axis
        // selection" some day, which might need "adapt to data extent on the
        // otherAxis", which is disabled by filterMode-'empty'.
        // But currently, stack has been fixed to based on value but not index,
        // so this is not an issue any more.
        // let otherAxisModel = this.getOtherAxisModel();
        // if (dataZoomModel.get('$fromToolbox')
        //     && otherAxisModel
        //     && otherAxisModel.hasSeriesStacked
        // ) {
        //     filterMode = 'empty';
        // }

        // TODO
        // filterMode 'weakFilter' and 'empty' is not optimized for huge data yet.

        each(seriesModels, function (seriesModel) {
            let seriesData = seriesModel.getData();
            const dataDims = seriesData.mapDimensionsAll(axisDim);

            if (!dataDims.length) {
                return;
            }

            if (filterMode === 'weakFilter') {
                const store = seriesData.getStore();
                const dataDimIndices = map(dataDims, dim => seriesData.getDimensionIndex(dim), seriesData);
                seriesData.filterSelf(function (dataIndex) {
                    let leftOut;
                    let rightOut;
                    let hasValue;
                    for (let i = 0; i < dataDims.length; i++) {
                        const value = store.get(dataDimIndices[i], dataIndex) as number;
                        const thisHasValue = !isNaN(value);
                        const thisLeftOut = value < valueWindow[0];
                        const thisRightOut = value > valueWindow[1];
                        if (thisHasValue && !thisLeftOut && !thisRightOut) {
                            return true;
                        }
                        thisHasValue && (hasValue = true);
                        thisLeftOut && (leftOut = true);
                        thisRightOut && (rightOut = true);
                    }
                    // If both left out and right out, do not filter.
                    return hasValue && leftOut && rightOut;
                });
            }
            else {
                each(dataDims, function (dim) {
                    if (filterMode === 'empty') {
                        seriesModel.setData(
                            seriesData = seriesData.map(dim, function (value: number) {
                                return !isInWindow(value) ? NaN : value;
                            })
                        );
                    }
                    else {
                        const range: Dictionary<[number, number]> = {};
                        range[dim] = valueWindow as [number, number];

                        // console.time('AxisProxy_selectRange');
                        seriesData.selectRange(range);
                        // console.timeEnd('AxisProxy_selectRange');
                    }
                });
            }

            each(dataDims, function (dim) {
                seriesData.setApproximateExtent(valueWindow as [number, number], dim);
            });
        });

        function isInWindow(value: number) {
            return value >= valueWindow[0] && value <= valueWindow[1];
        }
    }

    private _updateMinMaxSpan() {
        const minMaxSpan = this._minMaxSpan = {} as MinMaxSpan;
        const dataZoomModel = this._dataZoomModel;
        const dataExtent = this._extent;

        each(['min', 'max'], function (minMax) {
            let percentSpan = dataZoomModel.get(minMax + 'Span' as 'minSpan' | 'maxSpan');
            let valueSpan = dataZoomModel.get(minMax + 'ValueSpan' as 'minValueSpan' | 'maxValueSpan');
            valueSpan != null && (valueSpan = this.getAxisModel().axis.scale.parse(valueSpan));

            // minValueSpan and maxValueSpan has higher priority than minSpan and maxSpan
            if (valueSpan != null) {
                percentSpan = linearMap(
                    dataExtent[0] + valueSpan, dataExtent, [0, 100], true
                );
            }
            else if (percentSpan != null) {
                valueSpan = linearMap(
                    percentSpan, [0, 100], dataExtent, true
                ) - dataExtent[0];
            }

            minMaxSpan[minMax + 'Span' as 'minSpan' | 'maxSpan'] = percentSpan;
            minMaxSpan[minMax + 'ValueSpan' as 'minValueSpan' | 'maxValueSpan'] = valueSpan;
        }, this);
    }
}

export default AxisProxy;
