// (C) 2007-2020 GoodData Corporation
import invariant from "invariant";
import qs from "qs";
import range from "lodash/range";
import get from "lodash/get";
import { Execution, AFM } from "@gooddata/typings";

import { XhrModule, ApiResponseError } from "../xhr";
import { convertExecutionToJson } from "./execute-afm.convert";

export const DEFAULT_LIMIT = 1000;

/**
 * This interface represents input for executeVisualization API endpoint.
 *
 * NOTE: all functionality related to executeVisualization is experimental and subject to possible breaking changes
 * in the future; location and shape of this interface WILL change when the functionality is made GA.
 *
 * @private
 * @internal
 */
export interface IVisualizationExecution {
    visualizationExecution: {
        reference: string;
        resultSpec?: AFM.IResultSpec;
        filters?: AFM.CompatibilityFilter[];
    };
}

/**
 * This interface represents error caused during second part of api execution (data fetching)
 * and contains information about first execution part if that part was successful.
 */
export class ApiExecutionResponseError extends ApiResponseError {
    constructor(error: ApiResponseError, public executionResponse: any) {
        super(error.message, error.response, error.responseBody);
    }
}

export class ExecuteAfmModule {
    constructor(private xhr: XhrModule) {}

    /**
     * Execute AFM and fetch all data results
     *
     * @method executeAfm
     * @param {String} projectId - GD project identifier
     * @param {AFM.IExecution} execution - See https://github.com/gooddata/gooddata-typings/blob/v2.1.0/src/AFM.ts#L2
     *
     * @returns {Promise<Execution.IExecutionResponses>} Structure with `executionResponse` and `executionResult` -
     *  See https://github.com/gooddata/gooddata-typings/blob/v2.1.0/src/Execution.ts#L113
     */
    public executeAfm(projectId: string, execution: AFM.IExecution): Promise<Execution.IExecutionResponses> {
        validateNumOfDimensions(get(execution, "execution.resultSpec.dimensions").length);
        return this.getExecutionResponse(projectId, execution).then(
            (executionResponse: Execution.IExecutionResponse) => {
                return this.getExecutionResult(executionResponse.links.executionResult)
                    .then((executionResult: Execution.IExecutionResult | null) => {
                        return { executionResponse, executionResult };
                    })
                    .catch(error => {
                        throw new ApiExecutionResponseError(error, executionResponse);
                    });
            },
        );
    }

    /**
     * Execute AFM and return execution's response; the response describes dimensionality of the results and
     * includes link to poll for the results.
     *
     * @method getExecutionResponse
     * @param {string} projectId - GD project identifier
     * @param {AFM.IExecution} execution - See https://github.com/gooddata/gooddata-typings/blob/v2.1.0/src/AFM.ts#L2
     *
     * @returns {Promise<Execution.IExecutionResponse>} Promise with `executionResponse`
     *  See https://github.com/gooddata/gooddata-typings/blob/v2.1.0/src/Execution.ts#L69
     */
    public getExecutionResponse(
        projectId: string,
        execution: AFM.IExecution,
    ): Promise<Execution.IExecutionResponse> {
        validateNumOfDimensions(get(execution, "execution.resultSpec.dimensions").length);
        return this.xhr
            .post(`/gdc/app/projects/${projectId}/executeAfm`, { body: convertExecutionToJson(execution) })
            .then(apiResponse => apiResponse.getData())
            .then(unwrapExecutionResponse);
    }

    /**
     * Execute saved visualization and get all data.
     *
     * NOTE: all functionality related to executeVisualization is experimental and subject to possible breaking changes
     * in the future; location and shape of this interface WILL change when the functionality is made GA.
     *
     * @param {string} projectId - GD project identifier
     * @param {IVisualizationExecution} visExecution - execution payload
     *
     * @private
     * @internal
     */
    public _executeVisualization(
        projectId: string,
        visExecution: IVisualizationExecution,
    ): Promise<Execution.IExecutionResponses> {
        // We have ONE-3961 as followup to take this out of experimental mode

        return this._getVisExecutionResponse(projectId, visExecution).then(
            (executionResponse: Execution.IExecutionResponse) => {
                return this.getExecutionResult(executionResponse.links.executionResult).then(
                    (executionResult: Execution.IExecutionResult | null) => {
                        return { executionResponse, executionResult };
                    },
                );
            },
        );
    }

    /**
     *
     * Execute visualization and return the response; the response describes dimensionality of the results and
     * includes link to poll for the results.
     *
     * NOTE: all functionality related to executeVisualization is experimental and subject to possible breaking changes
     * in the future; location and shape of this interface WILL change when the functionality is made GA.
     *
     * @param {string} projectId - GD project identifier
     * @param {IVisualizationExecution} visExecution - execution payload
     *
     * @private
     * @internal
     */
    public _getVisExecutionResponse(
        projectId: string,
        visExecution: IVisualizationExecution,
    ): Promise<Execution.IExecutionResponse> {
        // We have ONE-3961 as followup to take this out of experimental mode

        const body = createExecuteVisualizationBody(visExecution);

        return this.xhr
            .post(`/gdc/app/projects/${projectId}/executeVisualization`, { body })
            .then(apiResponse => apiResponse.getData())
            .then(unwrapExecutionResponse);
    }

    //
    // working with results
    //

    /**
     * Get one page of Result from Execution (with requested limit and offset)
     *
     * @method getPartialExecutionResult
     * @param {string} executionResultUri
     * @param {number[]} limit - limit for each dimension
     * @param {number[]} offset - offset for each dimension
     *
     * @returns {Promise<Execution.IExecutionResult | null>}
     *  Promise with `executionResult` or `null` (null means empty response - HTTP 204)
     *  See https://github.com/gooddata/gooddata-typings/blob/v2.1.0/src/Execution.ts#L88
     */
    public getPartialExecutionResult(
        executionResultUri: string,
        limit: number[],
        offset: number[],
    ): Promise<Execution.IExecutionResult | null> {
        const executionResultUriQueryPart = getExecutionResultUriQueryPart(executionResultUri);
        const numOfDimensions = Number(qs.parse(executionResultUriQueryPart).dimensions);
        validateNumOfDimensions(numOfDimensions);

        return this.getPage(executionResultUri, limit, offset);
    }

    /**
     * Get whole ExecutionResult
     *
     * @method getExecutionResult
     * @param {string} executionResultUri
     *
     * @returns {Promise<Execution.IExecutionResult | null>}
     *  Promise with `executionResult` or `null` (null means empty response - HTTP 204)
     *  See https://github.com/gooddata/gooddata-typings/blob/v2.1.0/src/Execution.ts#L88
     */
    public getExecutionResult(executionResultUri: string): Promise<Execution.IExecutionResult | null> {
        const executionResultUriQueryPart = getExecutionResultUriQueryPart(executionResultUri);
        const numOfDimensions = Number(qs.parse(executionResultUriQueryPart).dimensions);
        validateNumOfDimensions(numOfDimensions);

        const limit = Array(numOfDimensions).fill(DEFAULT_LIMIT);
        const offset = Array(numOfDimensions).fill(0);

        return this.getAllPages(executionResultUri, limit, offset);
    }

    private getPage(
        executionResultUri: string,
        limit: number[],
        offset: number[],
    ): Promise<Execution.IExecutionResult | null> {
        return this.fetchExecutionResult(executionResultUri, limit, offset).then(
            (executionResultWrapper: Execution.IExecutionResultWrapper | null) => {
                return executionResultWrapper ? unwrapExecutionResult(executionResultWrapper) : null;
            },
        );
    }

    private getAllPages(
        executionResultUri: string,
        limit: number[],
        offset: number[],
        prevExecutionResult?: Execution.IExecutionResult,
    ): Promise<Execution.IExecutionResult | null> {
        return this.fetchExecutionResult(executionResultUri, limit, offset).then(
            (executionResultWrapper: Execution.IExecutionResultWrapper | null) => {
                if (!executionResultWrapper) {
                    return null;
                }

                const executionResult = unwrapExecutionResult(executionResultWrapper);

                const newExecutionResult = prevExecutionResult
                    ? mergePage(prevExecutionResult, executionResult)
                    : executionResult;

                const { offset, total } = executionResult.paging;
                const nextOffset = getNextOffset(limit, offset, total);
                const nextLimit = getNextLimit(limit, nextOffset, total);

                return nextPageExists(nextOffset, total)
                    ? this.getAllPages(executionResultUri, nextLimit, nextOffset, newExecutionResult)
                    : newExecutionResult;
            },
        );
    }

    private fetchExecutionResult(
        executionResultUri: string,
        limit: number[],
        offset: number[],
    ): Promise<Execution.IExecutionResultWrapper | null> {
        const uri = replaceLimitAndOffsetInUri(executionResultUri, limit, offset);

        return this.xhr
            .get(uri)
            .then(apiResponse => (apiResponse.response.status === 204 ? null : apiResponse.getData()));
    }
}

function getExecutionResultUriQueryPart(executionResultUri: string): string {
    return executionResultUri.split(/\?(.+)/)[1];
}

function unwrapExecutionResponse(
    executionResponseWrapper: Execution.IExecutionResponseWrapper,
): Execution.IExecutionResponse {
    return executionResponseWrapper.executionResponse;
}

function unwrapExecutionResult(
    executionResultWrapper: Execution.IExecutionResultWrapper,
): Execution.IExecutionResult {
    return executionResultWrapper.executionResult;
}

function validateNumOfDimensions(numOfDimensions: number): void {
    invariant(
        numOfDimensions === 1 || numOfDimensions === 2,
        `${numOfDimensions} dimensions are not allowed. Only 1 or 2 dimensions are supported.`,
    );
}

function createExecuteVisualizationBody(visExecution: IVisualizationExecution): string {
    const { reference, resultSpec, filters } = visExecution.visualizationExecution;
    const resultSpecProp = resultSpec ? { resultSpec } : undefined;
    const filtersProp = filters ? { filters } : undefined;

    return JSON.stringify({
        visualizationExecution: {
            reference,
            ...resultSpecProp,
            ...filtersProp,
        },
    });
}

export function replaceLimitAndOffsetInUri(oldUri: string, limit: number[], offset: number[]): string {
    const [uriPart, queryPart] = oldUri.split(/\?(.+)/);
    const query = {
        ...qs.parse(queryPart),
        limit: limit.join(","),
        offset: offset.join(","),
    };

    return uriPart + qs.stringify(query, { addQueryPrefix: true });
}

export function getNextOffset(limit: number[], offset: number[], total: number[]): number[] {
    const numOfDimensions = total.length;
    const defaultNextRowsOffset = offset[0] + limit[0];

    if (numOfDimensions === 1) {
        return [defaultNextRowsOffset];
    }

    const defaultNextColumnsOffset = offset[1] + limit[1];
    const nextColumnsExist = offset[1] + limit[1] < total[1];

    const nextRowsOffset = nextColumnsExist
        ? offset[0] // stay in the same rows
        : defaultNextRowsOffset; // go to the next rows

    const nextColumnsOffset = nextColumnsExist
        ? defaultNextColumnsOffset // next columns for the same rows
        : 0; // start in the beginning of the next rows

    return [nextRowsOffset, nextColumnsOffset];
}

export function getNextLimit(limit: number[], nextOffset: number[], total: number[]): number[] {
    const numOfDimensions = total.length;
    validateNumOfDimensions(numOfDimensions);

    const getSingleNextLimit = (limit: number, nextOffset: number, total: number): number =>
        nextOffset + limit > total ? total - nextOffset : limit;

    // prevent set up lower limit than possible for 2nd dimension in the beginning of the next rows
    if (
        numOfDimensions === 2 &&
        nextOffset[1] === 0 && // beginning of the next rows
        limit[0] < total[1] // limit from 1st dimension should be used in 2nd dimension
    ) {
        return [getSingleNextLimit(limit[0], nextOffset[0], total[0]), limit[0]];
    }

    return range(numOfDimensions).map((i: number) => getSingleNextLimit(limit[i], nextOffset[i], total[i]));
}

export function nextPageExists(nextOffset: number[], total: number[]): boolean {
    // expression "return nextLimit[0] > 0" also returns correct result
    return nextOffset[0] < total[0];
}

function mergeHeaderItemsForEachAttribute(
    dimension: number,
    headerItems: Execution.IResultHeaderItem[][][] | undefined,
    result: Execution.IExecutionResult,
) {
    if (headerItems && result.headerItems) {
        for (let attrIdx = 0; attrIdx < headerItems[dimension].length; attrIdx += 1) {
            result.headerItems[dimension][attrIdx].push(...headerItems[dimension][attrIdx]);
        }
    }
}

// works only for one or two dimensions
export function mergePage(
    prevExecutionResult: Execution.IExecutionResult,
    executionResult: Execution.IExecutionResult,
): Execution.IExecutionResult {
    const result = prevExecutionResult;
    const { headerItems, data, paging } = executionResult;

    const mergeHeaderItems = (dimension: number) => {
        // for 1 dimension we already have the headers from first page
        const otherDimension = dimension === 0 ? 1 : 0;
        const isEdge = paging.offset[otherDimension] === 0;
        if (isEdge) {
            mergeHeaderItemsForEachAttribute(dimension, headerItems, result);
        }
    };

    // merge data
    const rowOffset = paging.offset[0];
    if (result.data[rowOffset]) {
        // appending columns to existing rows
        for (let i = 0; i < data.length; i += 1) {
            const columns = data[i] as Execution.DataValue[];
            const resultData = result.data[i + rowOffset] as Execution.DataValue[];
            resultData.push(...columns);
        }
    } else {
        // appending new rows
        const resultData = result.data as Execution.DataValue[];
        const currentPageData = data as Execution.DataValue[];
        resultData.push(...currentPageData);
    }

    // merge headerItems
    if (paging.offset.length > 1) {
        mergeHeaderItems(0);
        mergeHeaderItems(1);
    } else {
        mergeHeaderItemsForEachAttribute(0, headerItems, result);
    }

    // update page count
    if (paging.offset.length === 1) {
        result.paging.count = [get(result, "headerItems[0][0]", []).length];
    }
    if (paging.offset.length === 2) {
        result.paging.count = [
            get(result, "headerItems[0][0]", []).length,
            get(result, "headerItems[1][0]", []).length,
        ];
    }

    return result;
}
