/*
* 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, indexOf, curry, assert, map, createHashMap } from 'zrender/src/core/util';
import * as graphic from '../../util/graphic';
import * as brushHelper from './brushHelper';
import {
    BrushPanelConfig, BrushControllerEvents, BrushType,
    BrushAreaRange, BrushDimensionMinMax
} from './BrushController';
import ExtensionAPI from '../../core/ExtensionAPI';
import GridModel from '../../coord/cartesian/GridModel';
import GeoModel from '../../coord/geo/GeoModel';
import { CoordinateSystemMaster } from '../../coord/CoordinateSystem';
import Cartesian2D from '../../coord/cartesian/Cartesian2D';
import Geo from '../../coord/geo/Geo';
import GlobalModel from '../../model/Global';
import { BrushAreaParam, BrushAreaParamInternal } from '../brush/BrushModel';
import SeriesModel from '../../model/Series';
import { Dictionary } from '../../util/types';
import {
    ModelFinderObject, ModelFinder,
    parseFinder as modelUtilParseFinder,
    ParsedModelFinderKnown,
    initExtentForUnion
} from '../../util/model';
import { viewCoordSysCopyBoundingRect, viewCoordSysCopyOverallMatrix } from '../../coord/View';
import { boundingRectApplyTransform } from 'zrender/src/core/BoundingRect';

type COORD_CONVERTS_INDEX = 0 | 1;

// FIXME
// how to genarialize to more coordinate systems.
const INCLUDE_FINDER_MAIN_TYPES = [
    'grid', 'xAxis', 'yAxis', 'geo', 'graph',
    'polar', 'radiusAxis', 'angleAxis', 'bmap'
];

type BrushableCoordinateSystem = Cartesian2D | Geo;
type BrushTargetBuilderKey = 'grid' | 'geo';

/**
 * There can be multiple axes in a single targetInfo. Consider the case
 * of `grid` component, a targetInfo represents a grid which contains one or more
 * cartesian and one or more axes. And consider the case of parallel system,
 * which has multiple axes in a coordinate system.
 */
interface BrushTargetInfo {
    panelId: string;
    coordSysModel: CoordinateSystemMaster['model'];
    // Use the first one as the representitive coordSys.
    // A representitive cartesian in grid (first cartesian by default).
    coordSys: BrushableCoordinateSystem;
    // All cartesians.
    coordSyses: BrushableCoordinateSystem[];
    getPanelRect: GetPanelRect,
}
export interface BrushTargetInfoCartesian2D extends BrushTargetInfo {
    gridModel: GridModel;
    coordSys: Cartesian2D;
    coordSyses: Cartesian2D[];
    xAxisDeclared: boolean;
    yAxisDeclared: boolean;
}
export interface BrushTargetInfoGeo extends BrushTargetInfo {
    geoModel: GeoModel,
    coordSysModel: GeoModel,
    coordSys: Geo,
    coordSyses: Geo[],
}
type GetPanelRect = () => graphic.BoundingRect;


class BrushTargetManager {

    private _targetInfoList: BrushTargetInfo[] = [];

    /**
     * @param finder contains Index/Id/Name of xAxis/yAxis/geo/grid
     *        Each can be {number|Array.<number>}. like: {xAxisIndex: [3, 4]}
     * @param opt.include include coordinate system types.
     */
    constructor(
        finder: ModelFinderObject,
        ecModel: GlobalModel,
        opt?: {include?: BrushTargetBuilderKey[]}
    ) {
        const foundCpts = parseFinder(ecModel, finder);

        each(targetInfoBuilders, (builder, type) => {
            if (!opt || !opt.include || indexOf(opt.include, type) >= 0) {
                builder(foundCpts, this._targetInfoList);
            }
        });
    }

    setOutputRanges(
        areas: BrushControllerEvents['brush']['areas'],
        ecModel: GlobalModel
    ): BrushAreaParam[] {
        this.matchOutputRanges(areas, ecModel, function (
            area: BrushAreaParam,
            coordRange: ReturnType<ConvertCoord>['values'],
            coordSys: BrushableCoordinateSystem
        ) {
            (area.coordRanges || (area.coordRanges = [])).push(coordRange);
            // area.coordRange is the first of area.coordRanges
            if (!area.coordRange) {
                area.coordRange = coordRange;
                // In 'category' axis, coord to pixel is not reversible, so we can not
                // rebuild range by coordRange accrately, which may bring trouble when
                // brushing only one item. So we use __rangeOffset to rebuilding range
                // by coordRange. And this it only used in brush component so it is no
                // need to be adapted to coordRanges.
                const result = coordConvert[area.brushType](0, coordSys, coordRange);
                area.__rangeOffset = {
                    offset: diffProcessor[area.brushType](result.values, area.range, [1, 1]),
                    xyMinMax: result.xyMinMax
                };
            }
        });
        return areas;
    }

    matchOutputRanges<T extends (
        Parameters<BrushTargetManager['findTargetInfo']>[0] & {
            brushType: BrushType;
            range: BrushAreaRange;
        }
    )>(
        areas: T[],
        ecModel: GlobalModel,
        cb: (
            area: T,
            coordRange: ReturnType<ConvertCoord>['values'],
            coordSys: BrushableCoordinateSystem,
            ecModel: GlobalModel
        ) => void
    ) {
        each(areas, function (area) {
            const targetInfo = this.findTargetInfo(area, ecModel);

            if (targetInfo && targetInfo !== true) {
                each(
                    targetInfo.coordSyses,
                    function (coordSys) {
                        const result = coordConvert[area.brushType](1, coordSys, area.range, true);
                        cb(area, result.values, coordSys, ecModel);
                    }
                );
            }
        }, this);
    }

    /**
     * the `areas` is `BrushModel.areas`.
     * Called in layout stage.
     * convert `area.coordRange` to global range and set panelId to `area.range`.
     */
    setInputRanges(
        areas: BrushAreaParamInternal[],
        ecModel: GlobalModel
    ): void {
        each(areas, function (area) {
            const targetInfo = this.findTargetInfo(area, ecModel);

            if (__DEV__) {
                assert(
                    !targetInfo || targetInfo === true || area.coordRange,
                    'coordRange must be specified when coord index specified.'
                );
                assert(
                    !targetInfo || targetInfo !== true || area.range,
                    'range must be specified in global brush.'
                );
            }

            area.range = area.range || [];

            // convert coordRange to global range and set panelId.
            if (targetInfo && targetInfo !== true) {
                area.panelId = targetInfo.panelId;
                // (1) area.range should always be calculate from coordRange but does
                // not keep its original value, for the sake of the dataZoom scenario,
                // where area.coordRange remains unchanged but area.range may be changed.
                // (2) Only support converting one coordRange to pixel range in brush
                // component. So do not consider `coordRanges`.
                // (3) About __rangeOffset, see comment above.
                const result = coordConvert[area.brushType](0, targetInfo.coordSys, area.coordRange);
                const rangeOffset = area.__rangeOffset;
                area.range = rangeOffset
                    ? diffProcessor[area.brushType](
                        result.values,
                        rangeOffset.offset,
                        getScales(result.xyMinMax, rangeOffset.xyMinMax)
                    )
                    : result.values;
            }
        }, this);
    }

    makePanelOpts(
        api: ExtensionAPI,
        getDefaultBrushType?: (targetInfo: BrushTargetInfo) => BrushType
    ): BrushPanelConfig[] {
        return map(this._targetInfoList, function (targetInfo) {
            const rect = targetInfo.getPanelRect();
            return {
                panelId: targetInfo.panelId,
                defaultBrushType: getDefaultBrushType ? getDefaultBrushType(targetInfo) : null,
                clipPath: brushHelper.makeRectPanelClipPath(rect),
                isTargetByCursor: brushHelper.makeRectIsTargetByCursor(
                    rect, api, targetInfo.coordSysModel
                ),
                getLinearBrushOtherExtent: brushHelper.makeLinearBrushOtherExtent(rect)
            };
        });
    }

    controlSeries(area: BrushAreaParamInternal, seriesModel: SeriesModel, ecModel: GlobalModel): boolean {
        // Check whether area is bound in coord, and series do not belong to that coord.
        // If do not do this check, some brush (like lineX) will controll all axes.
        const targetInfo = this.findTargetInfo(area, ecModel);
        return targetInfo === true || (
            targetInfo && indexOf(
                targetInfo.coordSyses, seriesModel.coordinateSystem as BrushableCoordinateSystem
            ) >= 0
        );
    }

    /**
     * If return Object, a coord found.
     * If return true, global found.
     * Otherwise nothing found.
     */
    findTargetInfo(
        area: ModelFinderObject & {
            panelId?: string
        },
        ecModel: GlobalModel
    ): BrushTargetInfo | true {
        const targetInfoList = this._targetInfoList;
        const foundCpts = parseFinder(ecModel, area);

        for (let i = 0; i < targetInfoList.length; i++) {
            const targetInfo = targetInfoList[i];
            const areaPanelId = area.panelId;
            if (areaPanelId) {
                if (targetInfo.panelId === areaPanelId) {
                    return targetInfo;
                }
            }
            else {
                for (let j = 0; j < targetInfoMatchers.length; j++) {
                    if (targetInfoMatchers[j](foundCpts, targetInfo)) {
                        return targetInfo;
                    }
                }
            }
        }

        return true;
    }

}

function formatMinMax(minMax: BrushDimensionMinMax): BrushDimensionMinMax {
    minMax[0] > minMax[1] && minMax.reverse();
    return minMax;
}

function parseFinder(
    ecModel: GlobalModel, finder: ModelFinder
): ParsedModelFinderKnown {
    return modelUtilParseFinder(
        ecModel, finder, {includeMainTypes: INCLUDE_FINDER_MAIN_TYPES}
    );
}

type TargetInfoBuilder = (
    foundCpts: ParsedModelFinderKnown, targetInfoList: BrushTargetInfo[]
) => void;
const targetInfoBuilders: Record<BrushTargetBuilderKey, TargetInfoBuilder> = {

    grid: function (foundCpts, targetInfoList) {
        const xAxisModels = foundCpts.xAxisModels;
        const yAxisModels = foundCpts.yAxisModels;
        const gridModels = foundCpts.gridModels;
        // Remove duplicated.
        const gridModelMap = createHashMap<GridModel>();
        const xAxesHas = {} as Dictionary<boolean>;
        const yAxesHas = {} as Dictionary<boolean>;

        if (!xAxisModels && !yAxisModels && !gridModels) {
            return;
        }

        each(xAxisModels, function (axisModel) {
            const gridModel = axisModel.axis.grid.model;
            gridModelMap.set(gridModel.id, gridModel);
            xAxesHas[gridModel.id] = true;
        });
        each(yAxisModels, function (axisModel) {
            const gridModel = axisModel.axis.grid.model;
            gridModelMap.set(gridModel.id, gridModel);
            yAxesHas[gridModel.id] = true;
        });
        each(gridModels, function (gridModel) {
            gridModelMap.set(gridModel.id, gridModel);
            xAxesHas[gridModel.id] = true;
            yAxesHas[gridModel.id] = true;
        });

        gridModelMap.each(function (gridModel) {
            const grid = gridModel.coordinateSystem;
            const cartesians = [] as Cartesian2D[];

            each(grid.getCartesians(), function (cartesian, index) {
                if (indexOf(xAxisModels, cartesian.getAxis('x').model) >= 0
                    || indexOf(yAxisModels, cartesian.getAxis('y').model) >= 0
                ) {
                    cartesians.push(cartesian);
                }
            });
            targetInfoList.push({
                panelId: 'grid--' + gridModel.id,
                gridModel: gridModel,
                coordSysModel: gridModel,
                // Use the first one as the representitive coordSys.
                coordSys: cartesians[0],
                coordSyses: cartesians,
                getPanelRect: panelRectBuilders.grid,
                xAxisDeclared: xAxesHas[gridModel.id],
                yAxisDeclared: yAxesHas[gridModel.id]
            } as BrushTargetInfoCartesian2D);
        });
    },

    geo: function (foundCpts, targetInfoList) {
        each(foundCpts.geoModels, function (geoModel: GeoModel) {
            const coordSys = geoModel.coordinateSystem;
            targetInfoList.push({
                panelId: 'geo--' + geoModel.id,
                geoModel: geoModel,
                coordSysModel: geoModel,
                coordSys: coordSys,
                coordSyses: [coordSys],
                getPanelRect: panelRectBuilders.geo
            } as BrushTargetInfoGeo);
        });
    }
};

type TargetInfoMatcher = (
    foundCpts: ParsedModelFinderKnown, targetInfo: BrushTargetInfo
) => boolean;
const targetInfoMatchers: TargetInfoMatcher[] = [

    // grid
    function (foundCpts, targetInfo) {
        const xAxisModel = foundCpts.xAxisModel;
        const yAxisModel = foundCpts.yAxisModel;
        let gridModel = foundCpts.gridModel;

        !gridModel && xAxisModel && (gridModel = xAxisModel.axis.grid.model);
        !gridModel && yAxisModel && (gridModel = yAxisModel.axis.grid.model);

        return gridModel && gridModel === (targetInfo as BrushTargetInfoCartesian2D).gridModel;
    },

    // geo
    function (foundCpts, targetInfo) {
        const geoModel = foundCpts.geoModel;
        return geoModel && geoModel === (targetInfo as BrushTargetInfoGeo).geoModel;
    }
];

type PanelRectBuilder = (this: BrushTargetInfo) => graphic.BoundingRect;
const panelRectBuilders: Record<BrushTargetBuilderKey, PanelRectBuilder> = {

    grid: function (this: BrushTargetInfoCartesian2D) {
        // grid is not Transformable.
        return this.coordSys.master.getRect().clone();
    },

    geo: function (this: BrushTargetInfoGeo) {
        const viewCoordSys = this.coordSys.view;
        const rect = viewCoordSysCopyBoundingRect(null, viewCoordSys);
        boundingRectApplyTransform(rect, rect, viewCoordSysCopyOverallMatrix(null, viewCoordSys));
        return rect;
    }
};

type ConvertCoord = (
    to: COORD_CONVERTS_INDEX,
    coordSys: BrushableCoordinateSystem,
    rangeOrCoordRange: BrushAreaRange,
    clamp?: boolean
) => {
    values: BrushAreaRange,
    xyMinMax: BrushDimensionMinMax[]
};
const coordConvert: Record<BrushType, ConvertCoord> = {

    lineX: curry(axisConvert, 0),

    lineY: curry(axisConvert, 1),

    rect: function (to, coordSys, rangeOrCoordRange: BrushDimensionMinMax[], clamp): {
        values: BrushDimensionMinMax[],
        xyMinMax: BrushDimensionMinMax[]
    } {
        const xminymin = to
            ? coordSys.pointToData([rangeOrCoordRange[0][0], rangeOrCoordRange[1][0]], clamp)
            : coordSys.dataToPoint([rangeOrCoordRange[0][0], rangeOrCoordRange[1][0]], clamp);
        const xmaxymax = to
            ? coordSys.pointToData([rangeOrCoordRange[0][1], rangeOrCoordRange[1][1]], clamp)
            : coordSys.dataToPoint([rangeOrCoordRange[0][1], rangeOrCoordRange[1][1]], clamp);
        const values = [
            formatMinMax([xminymin[0], xmaxymax[0]]),
            formatMinMax([xminymin[1], xmaxymax[1]])
        ];
        return {values: values, xyMinMax: values};
    },

    polygon: function (to, coordSys, rangeOrCoordRange: BrushDimensionMinMax[], clamp): {
        values: BrushDimensionMinMax[],
        xyMinMax: BrushDimensionMinMax[]
    } {
        const xyMinMax = [initExtentForUnion(), initExtentForUnion()];
        const values = map(rangeOrCoordRange, function (item) {
            const p = to ? coordSys.pointToData(item, clamp) : coordSys.dataToPoint(item, clamp);
            xyMinMax[0][0] = Math.min(xyMinMax[0][0], p[0]);
            xyMinMax[1][0] = Math.min(xyMinMax[1][0], p[1]);
            xyMinMax[0][1] = Math.max(xyMinMax[0][1], p[0]);
            xyMinMax[1][1] = Math.max(xyMinMax[1][1], p[1]);
            return p;
        });
        return {values: values, xyMinMax: xyMinMax};
    }
};

function axisConvert(
    axisNameIndex: 0 | 1,
    to: COORD_CONVERTS_INDEX,
    coordSys: Cartesian2D,
    rangeOrCoordRange: BrushDimensionMinMax
): {
    values: BrushDimensionMinMax,
    xyMinMax: BrushDimensionMinMax[]
} {
    if (__DEV__) {
        assert(
            coordSys.type === 'cartesian2d',
            'lineX/lineY brush is available only in cartesian2d.'
        );
    }

    const axis = coordSys.getAxis(['x', 'y'][axisNameIndex]);
    const values = formatMinMax(map([0, 1], function (i) {
        return to
            ? axis.coordToData(axis.toLocalCoord(rangeOrCoordRange[i]), true)
            : axis.toGlobalCoord(axis.dataToCoord(rangeOrCoordRange[i]));
    }));
    const xyMinMax = [];
    xyMinMax[axisNameIndex] = values;
    xyMinMax[1 - axisNameIndex] = [NaN, NaN];

    return {values: values, xyMinMax: xyMinMax};
}


type DiffProcess = (
    values: BrushDimensionMinMax | BrushDimensionMinMax[],
    refer: BrushDimensionMinMax | BrushDimensionMinMax[],
    scales: ReturnType<typeof getScales>
) => BrushDimensionMinMax | BrushDimensionMinMax[];

const diffProcessor: Record<BrushType, DiffProcess> = {

    lineX: curry(axisDiffProcessor, 0),

    lineY: curry(axisDiffProcessor, 1),

    rect: function (
        values: BrushDimensionMinMax[], refer: BrushDimensionMinMax[], scales: ReturnType<typeof getScales>
    ): BrushDimensionMinMax[] {
        return [
            [values[0][0] - scales[0] * refer[0][0], values[0][1] - scales[0] * refer[0][1]],
            [values[1][0] - scales[1] * refer[1][0], values[1][1] - scales[1] * refer[1][1]]
        ];
    },

    polygon: function (
        values: BrushDimensionMinMax[], refer: BrushDimensionMinMax[], scales: ReturnType<typeof getScales>
    ): BrushDimensionMinMax[] {
        return map(values, function (item, idx) {
            return [item[0] - scales[0] * refer[idx][0], item[1] - scales[1] * refer[idx][1]];
        });
    }
};

function axisDiffProcessor(
    axisNameIndex: 0 | 1,
    values: BrushDimensionMinMax,
    refer: BrushDimensionMinMax,
    scales: ReturnType<typeof getScales>
): BrushDimensionMinMax {
    return [
        values[0] - scales[axisNameIndex] * refer[0],
        values[1] - scales[axisNameIndex] * refer[1]
    ];
}

// We have to process scale caused by dataZoom manually,
// although it might be not accurate.
// Return [0~1, 0~1]
function getScales(xyMinMaxCurr: BrushDimensionMinMax[], xyMinMaxOrigin: BrushDimensionMinMax[]): number[] {
    const sizeCurr = getSize(xyMinMaxCurr);
    const sizeOrigin = getSize(xyMinMaxOrigin);
    const scales = [sizeCurr[0] / sizeOrigin[0], sizeCurr[1] / sizeOrigin[1]];
    isNaN(scales[0]) && (scales[0] = 1);
    isNaN(scales[1]) && (scales[1] = 1);
    return scales;
}

function getSize(xyMinMax: BrushDimensionMinMax[]): number[] {
    return xyMinMax
        ? [xyMinMax[0][1] - xyMinMax[0][0], xyMinMax[1][1] - xyMinMax[1][0]]
        : [NaN, NaN];
}

export default BrushTargetManager;
