// tslint:disable-line
/**
 * Calculate new min/max to make Y axes aligned, and insert them to Highcharts config
 *
 * Inspired by
 *      Author:  Christos Koumenides
 *      Page:    https://www.highcharts.com/products/plugin-registry/single/42/Zero-align%20y-axes
 *      Github:  https://github.com/chriskmnds/highcharts-zero-align-y-axes
 *
 * Modified by binh.nguyen@gooddata.com to support min/max configuration
 */

import partial = require("lodash/partial");
import isNil = require("lodash/isNil");
import zip = require("lodash/zip");
import sum = require("lodash/sum");
import compact = require("lodash/compact");

import { PERCENT_STACK } from "./getOptionalStackingConfiguration";
import { IChartOptions, IHighChartAxis, ISeriesDataItem, ISeriesItem } from "../../../../interfaces/Config";
import { isComboChart, isLineChart } from "../../utils/common";

export interface ICanon {
    min?: number;
    max?: number;
}

export type IMinMax = ICanon;

export interface IMinMaxInfo extends ICanon {
    id: number;
    isSetMin: boolean;
    isSetMax: boolean;
}

export interface IMinMaxLookup {
    0?: IMinMaxInfo;
    1?: IMinMaxInfo;
}

/**
 * Check if user sets min or max
 * @param minmax
 * @param index
 */
function isMinMaxConfig(minmax: IMinMaxInfo[], index: number): boolean {
    return minmax[index].isSetMin || minmax[index].isSetMax;
}

function isMinMaxConfigOnAnyAxis(minmax: IMinMaxInfo[]): boolean {
    return isMinMaxConfig(minmax, 0) || isMinMaxConfig(minmax, 1);
}

function minmaxCanon(minmax: IMinMaxInfo[]): ICanon[] {
    const canon: ICanon[] = [];
    const extremes = ["min", "max"];

    minmax.forEach((item: IMinMaxInfo, i: number) => {
        canon[i] = {};
        extremes.forEach((extreme: string) => {
            if (item[extreme] === 0) {
                canon[i][extreme] = 0;
            } else if (item[extreme] > 0) {
                canon[i][extreme] = 1;
            } else {
                canon[i][extreme] = -1;
            }
        });
    });

    return canon;
}

function getMinMaxLookup(minmax: IMinMaxInfo[]): IMinMaxLookup {
    return minmax.reduce((result: IMinMaxLookup, item: IMinMaxInfo) => {
        result[item.id] = item;
        return result;
    }, {});
}

function calculateMin(
    idx: number,
    minmax: IMinMaxInfo[],
    minmaxLookup: IMinMaxLookup,
    axisIndex: number,
): number {
    const fraction = !minmax[idx].max
        ? minmax[idx].min // handle divide zero case
        : minmax[idx].min / minmax[idx].max;
    return fraction * minmaxLookup[axisIndex].max;
}

function calculateMax(
    idx: number,
    minmax: IMinMaxInfo[],
    minmaxLookup: IMinMaxLookup,
    axisIndex: number,
): number {
    const fraction = !minmax[idx].min
        ? minmax[idx].max // handle divide zero case
        : minmax[idx].max / minmax[idx].min;
    return fraction * minmaxLookup[axisIndex].min;
}

/**
 * Calculate min or max and return it
 *
 * For min, the calculation is based on axis having smallest min in case having min/max setting
 * Otherwise, it is calculated base on axis having smaller min
 *
 * For max, the calculation is base on axis having largest max in case having min/max setting
 * Otherwise, it is calculated base on axis having larger max
 *
 * @param minmax
 * @param minmaxLookup
 * @param axisIndex
 * @param getIndex
 * @param calculateLimit
 * @param findExtreme
 */
function getLimit(
    minmax: IMinMaxInfo[],
    minmaxLookup: IMinMaxLookup,
    axisIndex: number,
    getIndex: (...params: any[]) => any, // TODO: make the types more specific (FET-282)
    calculateLimit: (...params: any[]) => any, // TODO: make the types more specific (FET-282)
    findExtreme: (...params: any[]) => any, // TODO: make the types more specific (FET-282)
): number {
    const isMinMaxConfig = isMinMaxConfigOnAnyAxis(minmax);
    if (isMinMaxConfig) {
        const idx = getIndex(minmax);
        return calculateLimit(idx, minmax, minmaxLookup, axisIndex);
    }
    return findExtreme([0, 1].map((index: number) => calculateLimit(index, minmax, minmaxLookup, axisIndex)));
}

export function getMinMax(axisIndex: number, min: number, max: number, minmax: IMinMaxInfo[]): IMinMax {
    const minmaxLookup: IMinMaxLookup = getMinMaxLookup(minmax);
    const axesCanon: ICanon[] = minmaxCanon(minmax);

    const getLimitPartial = partial(getLimit, minmax, minmaxLookup, axisIndex);

    let { min: newMin, max: newMax } = minmaxLookup[axisIndex];
    const { isSetMin, isSetMax } = minmaxLookup[axisIndex];

    if (axesCanon[0].min <= 0 && axesCanon[0].max <= 0 && axesCanon[1].min <= 0 && axesCanon[1].max <= 0) {
        // set 0 at top of chart
        // ['----', '-0--', '---0']
        newMax = Math.min(0, max);
    } else if (
        axesCanon[0].min >= 0 &&
        axesCanon[0].max >= 0 &&
        axesCanon[1].min >= 0 &&
        axesCanon[1].max >= 0
    ) {
        // set 0 at bottom of chart
        // ['++++', '0+++', '++0+']
        newMin = Math.max(0, min);
    } else if (axesCanon[0].max === axesCanon[1].max) {
        newMin = getLimitPartial(
            (minmax: IMinMaxInfo[]) => (minmax[0].min <= minmax[1].min ? 0 : 1),
            calculateMin,
            (minOnAxes: number[]) => Math.min(minOnAxes[0], minOnAxes[1]),
        );
    } else if (axesCanon[0].min === axesCanon[1].min) {
        newMax = getLimitPartial(
            (minmax: IMinMaxInfo[]) => (minmax[0].max > minmax[1].max ? 0 : 1),
            calculateMax,
            (maxOnAxes: number[]) => Math.max(maxOnAxes[0], maxOnAxes[1]),
        );
    } else {
        // set 0 at center of chart
        // ['--++', '-0++', '--0+', '-00+', '++--', '++-0', '0+--', '0+-0']
        if (minmaxLookup[axisIndex].min < 0) {
            newMax = Math.abs(newMin);
        } else {
            newMin = 0 - newMax;
        }
    }

    return {
        min: isSetMin ? minmaxLookup[axisIndex].min : newMin,
        max: isSetMax ? minmaxLookup[axisIndex].max : newMax,
    };
}

export function getMinMaxInfo(config: any, stacking: string, type: string): IMinMaxInfo[] {
    const { series, yAxis } = config;
    const isStackedChart = !isNil(stacking);

    return yAxis.map(
        (axis: IHighChartAxis, axisIndex: number): IMinMaxInfo => {
            const isLineChartOnAxis = isLineChartType(series, axisIndex, type);
            const seriesOnAxis = getSeriesOnAxis(series, axisIndex);
            const { min, max, opposite } = axis;

            const { min: dataMin, max: dataMax } =
                isStackedChart && !isLineChartOnAxis
                    ? getDataMinMaxOnStackedChart(seriesOnAxis, stacking, opposite)
                    : getDataMinMax(seriesOnAxis, isLineChartOnAxis);

            return {
                id: axisIndex,
                min: isNil(min) ? dataMin : min,
                max: isNil(max) ? dataMax : max,
                isSetMin: min !== undefined,
                isSetMax: max !== undefined,
            };
        },
    );
}

/**
 * Get series on related axis
 * @param axisIndex
 * @param series
 */
function getSeriesOnAxis(series: ISeriesItem[], axisIndex: number): ISeriesItem[] {
    return series.filter((item: ISeriesItem): boolean => item.yAxis === axisIndex);
}

/**
 * Get y value in series
 * @param series
 */
function getYDataInSeries(series: ISeriesItem): number[] {
    return series.data.map((item: ISeriesDataItem): number => item.y);
}

/**
 * Convert table of y value from row-view
 * [
 *  [1, 2, 3],
 *  [4, 5, 6]
 * ]
 * to column-view
 * [
 *  [1, [2, [3,
 *   4]  5]  6]
 * ]
 * @param yData
 */
function getStackedYData(yData: number[][]): number[][] {
    return zip(...yData);
}

/**
 * Get extreme on columns
 * [
 *  [1, [2, [3,
 *   4]  5]  6]
 * ]
 * @param columns
 * @return [min, max]
 */
function getColumnExtremes(columns: number[]): IMinMax {
    return columns.reduce(
        (result: IMinMax, item: number): IMinMax => {
            const extreme = item < 0 ? "min" : "max";
            result[extreme] += item;
            return result;
        },
        { min: 0, max: 0 },
    );
}

function getStackedDataMinMax(yData: number[][]): IMinMax {
    const isEmpty = yData.length === 0;
    let min = isEmpty ? 0 : Number.POSITIVE_INFINITY;
    let max = isEmpty ? 0 : Number.NEGATIVE_INFINITY;

    yData.forEach((column: number[]) => {
        const { min: columnDataMin, max: columnDataMax } = getColumnExtremes(column);
        min = Math.min(min, columnDataMin);
        max = Math.max(max, columnDataMax);
    });

    return { min, max };
}

/**
 * Convert number to percent base on total of column
 * From
 *  [
 *      [1, [3, [4, [null,  [20,
 *       4]  7] -6]  null],  null]
 *  ]
 * to
 *  [
 *      [20, [30, [40, [  , [100
 *       80]  70] -60]  ]      ]
 *  ]
 * @param yData
 */
export function convertNumberToPercent(yData: number[][]): number[][] {
    return yData.map((columns: number[]) => {
        const columnsWithoutNull = compact(columns); // remove null values
        const total = sum(columnsWithoutNull.map((num: number) => Math.abs(num)));
        return columnsWithoutNull.map((num: number) => (num / total) * 100);
    });
}

/**
 * Get data min/max in stacked chart
 * By comparing total of positive value to get max and total of negative value to get min
 * @param series
 * @param stacking
 * @param opposite
 */
function getDataMinMaxOnStackedChart(series: ISeriesItem[], stacking: string, opposite: boolean): IMinMax {
    const yData = series.map(getYDataInSeries);
    const stackedYData = getStackedYData(yData);
    if (stacking === PERCENT_STACK && !opposite) {
        const percentData = convertNumberToPercent(stackedYData);
        return getStackedDataMinMax(percentData);
    }
    return getStackedDataMinMax(stackedYData);
}

/**
 * Get data min/max in normal chart
 * By comparing min max value in all series in axis
 * @param series
 */
function getDataMinMax(series: ISeriesItem[], isLineChart: boolean): IMinMax {
    const { min, max } = series.reduce(
        (result: IMinMax, item: ISeriesItem): IMinMax => {
            const yData = getYDataInSeries(item);
            return {
                min: Math.min(result.min, ...yData),
                max: Math.max(result.max, ...yData),
            };
        },
        {
            min: Number.POSITIVE_INFINITY,
            max: Number.NEGATIVE_INFINITY,
        },
    );
    return {
        min: isLineChart ? min : Math.min(0, min),
        max: isLineChart ? max : Math.max(0, max),
    };
}

function isLineChartType(series: ISeriesItem[], axisIndex: number, type: string): boolean {
    if (isLineChart(type)) {
        return true;
    }
    if (isComboChart(type)) {
        return getSeriesOnAxis(series, axisIndex).every((item: ISeriesItem) => isLineChart(item.type));
    }
    return false;
}

function getExtremeByChartTypeOnAxis(
    extreme: number,
    series: ISeriesItem[],
    axisIndex: number,
    type: string,
): number {
    const isLineChartOnAxis = isLineChartType(series, axisIndex, type);
    if (isLineChartOnAxis) {
        return extreme;
    }
    return Math.min(0, extreme);
}

/**
 * Check whether axis has invalid min/max
 * @param minmax
 */
function hasInvalidAxis(minmax: IMinMaxInfo[]): boolean {
    return minmax.reduce((result: boolean, item: IMinMaxInfo) => {
        const { min, max } = item;
        if (min >= max) {
            return true;
        }
        return result;
    }, false);
}

/**
 * Hide invalid axis by setting 'visible' to false
 * @param config
 * @param minmax
 * @param type
 */
function hideInvalidAxis(config: any, minmax: IMinMaxInfo[], type: string) {
    const series: ISeriesItem[] = config.series.map((item: ISeriesItem) => {
        const { yAxis, type } = item;
        return type ? { yAxis, type } : { yAxis };
    });

    const yAxis: Array<Partial<IHighChartAxis>> = minmax.map((item: IMinMaxInfo, index: number) => {
        const isLineChartOnAxis = isLineChartType(series, index, type);
        const { min, max } = item;

        const shouldInvisible = isLineChartOnAxis ? min > max : min >= max;
        if (shouldInvisible) {
            return {
                visible: false,
            };
        }

        return {};
    });

    yAxis.forEach((axis: Partial<IHighChartAxis>, index: number) => {
        const { visible } = axis;
        if (visible === false) {
            series.forEach((item: ISeriesItem) => {
                if (item.yAxis === index) {
                    item.visible = false;
                }
            });
        }
    });

    return { yAxis, series };
}

/**
 * Calculate new min/max to make Y axes aligned
 * @param chartOptions
 * @param config
 */
export function getZeroAlignConfiguration(chartOptions: IChartOptions, config: any) {
    const { stacking, type } = chartOptions;
    const { yAxis } = config;
    const isDualAxis = (yAxis || []).length === 2;

    if (!isDualAxis) {
        return {};
    }

    const minmax: IMinMaxInfo[] = getMinMaxInfo(config, stacking, type);

    if (hasInvalidAxis(minmax)) {
        return hideInvalidAxis(config, minmax, type);
    }

    if (minmax[0].isSetMin && minmax[0].isSetMax && minmax[1].isSetMin && minmax[1].isSetMax) {
        // take user-input min/max, no need to calculate
        // this 'isUserMinMax' acts as a flag,
        // so that 'adjustTickAmount' plugin knows this min/max is either user input or calculated
        return {
            yAxis: [
                {
                    isUserMinMax: true,
                },
                {
                    isUserMinMax: true,
                },
            ],
        };
    }

    // calculate min/max on both Y axes and set it to HighCharts yAxis config
    const yAxisWithMinMax = [0, 1].map((axisIndex: number) => {
        const { min, max } = minmax[axisIndex];
        const newMinMax = getMinMax(
            axisIndex,
            getExtremeByChartTypeOnAxis(min, config.series, axisIndex, type),
            getExtremeByChartTypeOnAxis(max, config.series, axisIndex, type),
            minmax,
        );
        return {
            isUserMinMax: minmax[axisIndex].isSetMin || minmax[axisIndex].isSetMax,
            ...newMinMax,
        };
    });

    return {
        yAxis: yAxisWithMinMax,
    };
}
