// (C) 2007-2020 GoodData Corporation
import isPlainObject from "lodash/isPlainObject";
import get from "lodash/get";
import chunk from "lodash/chunk";
import flatten from "lodash/flatten";
import pick from "lodash/pick";
import { AFM, VisualizationObject } from "@gooddata/typings";
import { getIn, handlePolling, queryString } from "./util";
import { ApiResponse, ApiResponseError, XhrModule } from "./xhr";
import { IGetObjectsByQueryOptions, IGetObjectUsingOptions, SortDirection } from "./interfaces";
import { convertUrisToReferences, convertReferencesToUris } from "./referenceHandling";
import { convertAfm } from "./execution/execute-afm.convert";

export interface IValidElementsOptions {
    limit?: number;
    offset?: number;
    order?: SortDirection;
    filter?: string;
    prompt?: string;
    uris?: string[];
    complement?: boolean;
    includeTotalCountWithoutFilters?: boolean;
    restrictiveDefinition?: string;
    restrictiveDefinitionContent?: object;
    afm?: AFM.IAfm;
}

/**
 * Functions for working with metadata objects
 *
 * @class metadata
 * @module metadata
 */
export class MetadataModule {
    constructor(private xhr: XhrModule) {}

    /**
     * Load all objects with given uris
     * (use bulk loading instead of getting objects one by one)
     *
     * @method getObjects
     * @param {String} projectId id of the project
     * @param {Array} objectUris array of uris for objects to be loaded
     * @return {Array} array of loaded elements
     */
    public getObjects(projectId: string, objectUris: string[]): any {
        const LIMIT = 50;
        const uri = `/gdc/md/${projectId}/objects/get`;

        const objectsUrisChunks = chunk(objectUris, LIMIT);

        const promises = objectsUrisChunks.map(objectUrisChunk => {
            const body = {
                get: {
                    items: objectUrisChunk,
                },
            };

            return this.xhr
                .post(uri, { body })
                .then((r: ApiResponse) => {
                    if (!r.response.ok) {
                        throw new ApiResponseError(r.response.statusText, r.response, r.responseBody);
                    }

                    return r.getData();
                })
                .then((result: any) =>
                    get(result, ["objects", "items"]).map((item: any) => {
                        if (item.visualizationObject) {
                            return {
                                visualizationObject: convertReferencesToUris(item.visualizationObject),
                            };
                        }
                        if (item.visualizationWidget) {
                            return {
                                visualizationWidget: convertReferencesToUris(item.visualizationWidget),
                            };
                        }
                        return item;
                    }),
                );
        });

        return Promise.all(promises).then(flatten);
    }

    /**
     * Loads all objects by query (fetches all pages, one by one)
     *
     * @method getObjectsByQuery
     * @param {String} projectId id of the project
     * @param {Object} options (see https://developer.gooddata.com/api endpoint: /gdc/md/{project_id}/objects/query)
     *        - category {String} for example 'dataSets' or 'projectDashboard'
     *        - mode {String} 'enriched' or 'raw'
     *        - author {String} the URI of the author of the metadata objects
     *        - limit {number} default is 50 (also maximum)
     *        - deprecated {boolean} show also deprecated objects
     * @return {Promise<Array>} array of returned objects
     */
    public getObjectsByQuery(projectId: string, options: IGetObjectsByQueryOptions): Promise<any[]> {
        const getOnePage = (uri: string, items: any[] = []): Promise<any> => {
            return this.xhr
                .get(uri)
                .then((r: ApiResponse) => r.getData())
                .then(({ objects }: any) => {
                    items.push(...objects.items);
                    const nextUri = objects.paging.next;
                    return nextUri ? getOnePage(nextUri, items) : items;
                });
        };

        const deprecated = options.deprecated ? { deprecated: 1 } : {};
        const uri = `/gdc/md/${projectId}/objects/query`;
        const query = pick({ limit: 50, ...options, ...deprecated }, [
            "category",
            "mode",
            "author",
            "limit",
            "deprecated",
        ]);
        return getOnePage(uri + queryString(query));
    }

    /**
     * Get MD objects from using2 resource. Include only objects of given types
     * and take care about fetching only nearest objects if requested.
     *
     * @method getObjectUsing
     * @param {String} projectId id of the project
     * @param {String} uri uri of the object for which dependencies are to be found
     * @param {Object} options objects with options:
     *        - types {Array} array of strings with object types to be included
     *        - nearest {Boolean} whether to include only nearest dependencies
     * @return {jQuery promise} promise promise once resolved returns an array of
     *         entries returned by using2 resource
     */
    public getObjectUsing(projectId: string, uri: string, options: IGetObjectUsingOptions = {}) {
        const { types = [], nearest = false } = options;
        const resourceUri = `/gdc/md/${projectId}/using2`;

        const body = {
            inUse: {
                uri,
                types,
                nearest: nearest ? 1 : 0,
            },
        };

        return this.xhr
            .post(resourceUri, { body })
            .then((r: ApiResponse) => {
                if (!r.response.ok) {
                    throw new ApiResponseError(r.response.statusText, r.response, r.getData());
                }

                return r.getData();
            })
            .then((result: any) => result.entries);
    }

    /**
     * Get MD objects from using2 resource. Include only objects of given types
     * and take care about fetching only nearest objects if requested.
     *
     * @method getObjectUsingMany
     * @param {String} projectId id of the project
     * @param {Array} uris uris of objects for which dependencies are to be found
     * @param {Object} options objects with options:
     *        - types {Array} array of strings with object types to be included
     *        - nearest {Boolean} whether to include only nearest dependencies
     * @return {jQuery promise} promise promise once resolved returns an array of
     *         entries returned by using2 resource
     */
    public getObjectUsingMany(
        projectId: string,
        uris: string[],
        options: IGetObjectUsingOptions = {},
    ): Promise<any> {
        const { types = [], nearest = false } = options;
        const resourceUri = `/gdc/md/${projectId}/using2`;

        const body = {
            inUseMany: {
                uris,
                types,
                nearest: nearest ? 1 : 0,
            },
        };

        return this.xhr
            .post(resourceUri, { body })
            .then((r: ApiResponse) => {
                if (!r.response.ok) {
                    throw new ApiResponseError(r.response.statusText, r.response, r.getData());
                }

                return r.getData();
            })
            .then((result: any) => result.useMany);
    }

    /**
     * Returns all visualizationObjects metadata in a project specified by projectId param
     *
     * @method getVisualizations
     * @param {string} projectId Project identifier
     * @return {Array} An array of visualization objects metadata
     */
    public getVisualizations(projectId: string): Promise<any> {
        return this.xhr
            .get(`/gdc/md/${projectId}/query/visualizationobjects`)
            .then((apiResponse: ApiResponse) =>
                apiResponse.response.ok ? apiResponse.getData() : apiResponse.response,
            )
            .then(getIn("query.entries"));
    }

    /**
     * Returns all attributes in a project specified by projectId param
     *
     * @method getAttributes
     * @param {string} projectId Project identifier
     * @return {Array} An array of attribute objects
     */
    public getAttributes(projectId: string): Promise<any> {
        return this.xhr
            .get(`/gdc/md/${projectId}/query/attributes`)
            .then((apiResponse: ApiResponse) =>
                apiResponse.response.ok ? apiResponse.getData() : apiResponse.response,
            )
            .then(getIn("query.entries"));
    }

    /**
     * Returns all dimensions in a project specified by projectId param
     *
     * @method getDimensions
     * @param {string} projectId Project identifier
     * @return {Array} An array of dimension objects
     * @see getFolders
     */
    public getDimensions(projectId: string): Promise<any> {
        return this.xhr
            .get(`/gdc/md/${projectId}/query/dimensions`)
            .then((apiResponse: ApiResponse) =>
                apiResponse.response.ok ? apiResponse.getData() : apiResponse.response,
            )
            .then(getIn("query.entries"));
    }

    /**
     * Returns project folders. Folders can be of specific types and you can specify
     * the type you need by passing and optional `type` parameter
     *
     * @method getFolders
     * @param {String} projectId - Project identifier
     * @param {String} type - Optional, possible values are `metric`, `fact`, `attribute`
     * @return {Array} An array of dimension objects
     */
    public getFolders(projectId: string, type: string) {
        // TODO enum?
        const getFolderEntries = (pId: string, t: string) => {
            const typeURL = t ? `?type=${t}` : "";

            return this.xhr
                .get(`/gdc/md/${pId}/query/folders${typeURL}`)
                .then(r => r.getData())
                .then(getIn("query.entries"));
        };

        switch (type) {
            case "fact":
            case "metric":
                return getFolderEntries(projectId, type);
            case "attribute":
                return this.getDimensions(projectId);
            default:
                return Promise.all([
                    getFolderEntries(projectId, "fact"),
                    getFolderEntries(projectId, "metric"),
                    this.getDimensions(projectId),
                ]).then(([fact, metric, attribute]) => {
                    return { fact, metric, attribute };
                });
        }
    }

    /**
     * Returns all facts in a project specified by the given projectId
     *
     * @method getFacts
     * @param {string} projectId Project identifier
     * @return {Array} An array of fact objects
     */
    public getFacts(projectId: string): Promise<any> {
        return this.xhr
            .get(`/gdc/md/${projectId}/query/facts`)
            .then((apiResponse: ApiResponse) =>
                apiResponse.response.ok ? apiResponse.getData() : apiResponse.response,
            )
            .then(getIn("query.entries"));
    }

    /**
     * Returns all metrics in a project specified by the given projectId
     *
     * @method getMetrics
     * @param {string} projectId Project identifier
     * @return {Array} An array of metric objects
     */
    public getMetrics(projectId: string): Promise<any> {
        return this.xhr
            .get(`/gdc/md/${projectId}/query/metrics`)
            .then((apiResponse: ApiResponse) =>
                apiResponse.response.ok ? apiResponse.getData() : apiResponse.response,
            )
            .then(getIn("query.entries"));
    }

    /**
     * Returns all metrics that are reachable (with respect to ldm of the project
     * specified by the given projectId) for given attributes
     *
     * @method getAvailableMetrics
     * @param {String} projectId - Project identifier
     * @param {Array} attrs - An array of attribute uris for which we want to get
     * available metrics
     * @return {Array} An array of reachable metrics for the given attrs
     * @see getAvailableAttributes
     * @see getAvailableFacts
     */
    public getAvailableMetrics(projectId: string, attrs: string[] = []): Promise<any> {
        return this.xhr
            .post(`/gdc/md/${projectId}/availablemetrics`, { body: attrs })
            .then((apiResponse: ApiResponse) =>
                apiResponse.response.ok ? apiResponse.getData() : apiResponse.response,
            )
            .then((data: any) => data.entries);
    }

    /**
     * Returns all attributes that are reachable (with respect to ldm of the project
     * specified by the given projectId) for given metrics (also called as drillCrossPath)
     *
     * @method getAvailableAttributes
     * @param {String} projectId - Project identifier
     * @param {Array} metrics - An array of metric uris for which we want to get
     * available attributes
     * @return {Array} An array of reachable attributes for the given metrics
     * @see getAvailableMetrics
     * @see getAvailableFacts
     */
    public getAvailableAttributes(projectId: string, metrics: string[] = []): Promise<any> {
        return this.xhr
            .post(`/gdc/md/${projectId}/drillcrosspaths`, { body: metrics })
            .then(apiResponse => (apiResponse.response.ok ? apiResponse.getData() : apiResponse.response))
            .then((r: any) => r.drillcrosspath.links);
    }

    /**
     * Returns all attributes that are reachable (with respect to ldm of the project
     * specified by the given projectId) for given metrics (also called as drillCrossPath)
     *
     * @method getAvailableFacts
     * @param {String} projectId - Project identifier
     * @param {Array} items - An array of metric or attribute uris for which we want to get
     * available facts
     * @return {Array} An array of reachable facts for the given items
     * @see getAvailableAttributes
     * @see getAvailableMetrics
     */
    public getAvailableFacts(projectId: string, items: string[] = []): Promise<any> {
        return this.xhr
            .post(`/gdc/md/${projectId}/availablefacts`, { body: items })
            .then((r: ApiResponse) => (r.response.ok ? r.getData() : r.response))
            .then((r: any) => r.entries);
    }

    /**
     * Get details of a metadata object specified by its uri
     *
     * @method getObjectDetails
     * @param uri uri of the metadata object for which details are to be retrieved
     * @return {Object} object details
     */
    public getObjectDetails(uri: string): Promise<any> {
        return this.xhr.get(uri).then((r: ApiResponse) => r.getData());
    }

    /**
     * Get folders with items.
     * Returns array of folders, each having a title and items property which is an array of
     * corresponding items. Each item is either a metric or attribute, keeping its original
     * verbose structure.
     *
     * @method getFoldersWithItems
     * @param {String} type type of folders to return
     * @return {Array} Array of folder object, each containing title and
     * corresponding items.
     */
    public getFoldersWithItems(projectId: string, type: string) {
        // fetch all folders of given type and process them
        return this.getFolders(projectId, type).then(folders => {
            // Helper public to get details for each metric in the given
            // array of links to the metadata objects representing the metrics.
            // @return the array of promises
            const getMetricItemsDetails = (array: any[]) => {
                return Promise.all(array.map(this.getObjectDetails)).then(metricArgs => {
                    return metricArgs.map((item: any) => item.metric);
                });
            };

            // helper mapBy function
            function mapBy(array: any[], key: string) {
                return array.map((item: any) => {
                    return item[key];
                });
            }

            // helper for sorting folder tree structure
            // sadly @returns void (sorting == mutating array in js)
            const sortFolderTree = (structure: any[]) => {
                structure.forEach(folder => {
                    folder.items.sort((a: any, b: any) => {
                        if (a.meta.title < b.meta.title) {
                            return -1;
                        } else if (a.meta.title > b.meta.title) {
                            return 1;
                        }

                        return 0;
                    });
                });
                structure.sort((a, b) => {
                    if (a.title < b.title) {
                        return -1;
                    } else if (a.title > b.title) {
                        return 1;
                    }

                    return 0;
                });
            };

            const foldersLinks = mapBy(folders, "link");
            const foldersTitles = mapBy(folders, "title");

            // fetch details for each folder
            return Promise.all(foldersLinks.map(this.getObjectDetails)).then(folderDetails => {
                // if attribute, just parse everything from what we've received
                // and resolve. For metrics, lookup again each metric to get its
                // identifier. If passing unsupported type, reject immediately.
                if (type === "attribute") {
                    // get all attributes, subtract what we have and add rest in unsorted folder
                    return this.getAttributes(projectId).then(attributes => {
                        // get uris of attributes which are in some dimension folders
                        const attributesInFolders: any[] = [];
                        folderDetails.forEach((fd: any) => {
                            fd.dimension.content.attributes.forEach((attr: any) => {
                                attributesInFolders.push(attr.meta.uri);
                            });
                        });
                        // unsortedUris now contains uris of all attributes which aren't in a folder
                        const unsortedUris = attributes
                            .filter((item: any) => attributesInFolders.indexOf(item.link) === -1)
                            .map((item: any) => item.link);
                        // now get details of attributes in no folders
                        return Promise.all(unsortedUris.map(this.getObjectDetails)).then(
                            unsortedAttributeArgs => {
                                // TODO add map to r.json
                                // get unsorted attribute objects
                                const unsortedAttributes = unsortedAttributeArgs.map(
                                    (attr: any) => attr.attribute,
                                );
                                // create structure of folders with attributes
                                const structure = folderDetails.map((folderDetail: any) => {
                                    return {
                                        title: folderDetail.dimension.meta.title,
                                        items: folderDetail.dimension.content.attributes,
                                    };
                                });
                                // and append "Unsorted" folder with attributes to the structure
                                structure.push({
                                    title: "Unsorted",
                                    items: unsortedAttributes,
                                });
                                sortFolderTree(structure);

                                return structure;
                            },
                        );
                    });
                } else if (type === "metric") {
                    const entriesLinks = folderDetails.map((entry: any) =>
                        mapBy(entry.folder.content.entries, "link"),
                    );
                    // get all metrics, subtract what we have and add rest in unsorted folder
                    return this.getMetrics(projectId).then(metrics => {
                        // get uris of metrics which are in some dimension folders
                        const metricsInFolders: string[] = [];
                        folderDetails.forEach((fd: any) => {
                            fd.folder.content.entries.forEach((metric: any) => {
                                metricsInFolders.push(metric.link);
                            });
                        });
                        // unsortedUris now contains uris of all metrics which aren't in a folder
                        const unsortedUris = metrics
                            .filter((item: any) => metricsInFolders.indexOf(item.link) === -1)
                            .map((item: any) => item.link);

                        // sadly order of parameters of concat matters! (we want unsorted last)
                        entriesLinks.push(unsortedUris);

                        // now get details of all metrics
                        return Promise.all(
                            entriesLinks.map(linkArray => getMetricItemsDetails(linkArray)),
                        ).then(tree => {
                            // TODO add map to r.json
                            // all promises resolved, i.e. details for each metric are available
                            const structure = tree.map((treeItems, idx) => {
                                // if idx is not in folders list than metric is in "Unsorted" folder
                                return {
                                    title: foldersTitles[idx] || "Unsorted",
                                    items: treeItems,
                                };
                            });
                            sortFolderTree(structure);
                            return structure;
                        });
                    });
                }

                return Promise.reject(null);
            });
        });
    }

    /**
     * Get identifier of a metadata object identified by its uri
     *
     * @method getObjectIdentifier
     * @param uri uri of the metadata object for which the identifier is to be retrieved
     * @return {String} object identifier
     */
    public getObjectIdentifier(uri: string) {
        function idFinder(obj: any) {
            // TODO
            if (obj.attribute) {
                return obj.attribute.content.displayForms[0].meta.identifier;
            } else if (obj.dimension) {
                return obj.dimension.content.attributes.content.displayForms[0].meta.identifier;
            } else if (obj.metric) {
                return obj.metric.meta.identifier;
            }

            throw Error("Unknown object!");
        }

        if (!isPlainObject(uri)) {
            return this.getObjectDetails(uri).then(data => idFinder(data));
        }
        return Promise.resolve(idFinder(uri));
    }

    /**
     * Get uri of an metadata object, specified by its identifier and project id it belongs to
     *
     * @method getObjectUri
     * @param {string} projectId id of the project
     * @param identifier identifier of the metadata object
     * @return {String} uri of the metadata object
     */
    public getObjectUri(projectId: string, identifier: string) {
        return this.xhr
            .post(`/gdc/md/${projectId}/identifiers`, {
                body: {
                    identifierToUri: [identifier],
                },
            })
            .then((r: ApiResponse) => {
                const data = r.getData();
                const found = data.identifiers.find((pair: any) => pair.identifier === identifier);

                if (found) {
                    return found.uri;
                }

                throw new ApiResponseError(
                    `Object with identifier ${identifier} not found in project ${projectId}`,
                    r.response,
                    r.responseBody,
                );
            });
    }

    /**
     * Get uris specified by identifiers
     *
     * @method getUrisFromIdentifiers
     * @param {String} projectId id of the project
     * @param {Array} identifiers identifiers of the metadata objects
     * @return {Array} array of identifier + uri pairs
     */
    public getUrisFromIdentifiers(projectId: string, identifiers: string[]) {
        return this.xhr
            .post(`/gdc/md/${projectId}/identifiers`, {
                body: {
                    identifierToUri: identifiers,
                },
            })
            .then((r: ApiResponse) => r.getData())
            .then(data => {
                return data.identifiers;
            });
    }

    /**
     * Get identifiers specified by uris
     *
     * @method getIdentifiersFromUris
     * @param {String} projectId id of the project
     * @param {Array} uris of the metadata objects
     * @return {Array} array of identifier + uri pairs
     */
    public getIdentifiersFromUris(projectId: string, uris: string[]) {
        return this.xhr
            .post(`/gdc/md/${projectId}/identifiers`, {
                body: {
                    uriToIdentifier: uris,
                },
            })
            .then((r: ApiResponse) => r.getData())
            .then(data => {
                return data.identifiers;
            });
    }

    /**
     * Get attribute elements with their labels and uris.
     *
     * @param {String} projectId id of the project
     * @param {String} labelUri uri of the label (display form)
     * @param {Array<String>} patterns elements labels/titles (for EXACT mode), or patterns (for WILD mode)
     * @param {('EXACT'|'WILD')} mode match mode, currently only EXACT supported
     * @return {Array} array of elementLabelUri objects
     */
    public translateElementLabelsToUris(
        projectId: string,
        labelUri: string,
        patterns: string[],
        mode = "EXACT",
    ) {
        return this.xhr
            .post(`/gdc/md/${projectId}/labels`, {
                body: {
                    elementLabelToUri: [
                        {
                            labelUri,
                            mode,
                            patterns,
                        },
                    ],
                },
            })
            .then((r: ApiResponse) => (r.response.ok ? get(r.getData(), "elementLabelUri") : r.response));
    }

    /**
     * Get valid elements of an attribute, specified by its identifier and project id it belongs to
     *
     * @method getValidElements
     * @param {string} projectId id of the project
     * @param id display form id of the metadata object
     * @param {Object} options objects with options:
     *      - limit {Number}
     *      - offset {Number}
     *      - order {String} 'asc' or 'desc'
     *      - filter {String}
     *      - prompt {String}
     *      - uris {Array}
     *      - complement {Boolean}
     *      - includeTotalCountWithoutFilters {Boolean}
     *      - restrictiveDefinition {String}
     *      - afm {Object}
     * @return {Object} ValidElements response with:
     *      - items {Array} elements
     *      - paging {Object}
     *      - elementsMeta {Object}
     */
    public getValidElements(projectId: string, id: string, options: IValidElementsOptions = {}) {
        const query = pick(options, ["limit", "offset", "order", "filter", "prompt"]);
        const queryParams = queryString(query);
        const pickedOptions = pick(options, [
            "uris",
            "complement",
            "includeTotalCountWithoutFilters",
            "restrictiveDefinition",
        ]);
        const { afm } = options;

        const getRequestBodyWithReportDefinition = () =>
            this.xhr
                .post(`/gdc/app/projects/${projectId}/executeAfm/debug`, {
                    body: {
                        execution: {
                            afm: convertAfm(afm),
                        },
                    },
                })
                .then(response => response.getData())
                .then(reportDefinitionResult => ({
                    ...pickedOptions,
                    restrictiveDefinitionContent:
                        reportDefinitionResult.reportDefinitionWithInlinedMetrics.content,
                }));

        const getOptions = afm ? getRequestBodyWithReportDefinition : () => Promise.resolve(pickedOptions);

        return getOptions().then(requestBody =>
            this.xhr
                .post(`/gdc/md/${projectId}/obj/${id}/validElements${queryParams}`.replace(/\?$/, ""), {
                    body: {
                        validElementsRequest: requestBody,
                    },
                })
                .then(response => response.getData()),
        );
    }

    /**
     * Get visualization by Uri and process data
     *
     * @method getVisualization
     * @param {String} visualizationUri
     */
    public getVisualization(uri: string): Promise<VisualizationObject.IVisualization> {
        return this.getObjectDetails(uri).then(
            (visualizationObject: VisualizationObject.IVisualizationObjectResponse) => {
                const mdObject = visualizationObject.visualizationObject;
                return {
                    visualizationObject: convertReferencesToUris(
                        mdObject,
                    ) as VisualizationObject.IVisualizationObject,
                };
            },
        );
    }

    /**
     * Save visualization
     *
     * @method saveVisualization
     * @param {String} visualizationUri
     */
    public saveVisualization(projectId: string, visualization: VisualizationObject.IVisualization) {
        const converted = convertUrisToReferences(visualization.visualizationObject);
        return this.createObject(projectId, { visualizationObject: converted });
    }

    /**
     * Update visualization
     *
     * @method updateVisualization
     * @param {String} visualizationUri
     */
    public updateVisualization(
        projectId: string,
        visualizationUri: string,
        visualization: VisualizationObject.IVisualization,
    ) {
        const converted = convertUrisToReferences(visualization.visualizationObject);
        return this.updateObject(projectId, visualizationUri, { visualizationObject: converted });
    }

    /**
     * Delete visualization
     *
     * @method deleteVisualization
     * @param {String} visualizationUri
     */
    public deleteVisualization(visualizationUri: string) {
        return this.deleteObject(visualizationUri);
    }

    /**
     * Delete object
     *
     * @experimental
     * @method deleteObject
     * @param {String} uri of the object to be deleted
     */
    public deleteObject(uri: string) {
        return this.xhr.del(uri);
    }

    /**
     * Create object
     *
     * @experimental
     * @method createObject
     * @param {String} projectId
     * @param {String} obj object definition
     */
    public createObject(projectId: string, obj: any) {
        return this.xhr
            .post(`/gdc/md/${projectId}/obj?createAndGet=true`, {
                body: obj,
            })
            .then((r: ApiResponse) => r.getData());
    }

    /**
     * Update object
     *
     * @experimental
     * @method updateObject
     * @param {String} projectId
     * @param {String} visualizationUri
     * @param {String} obj object definition
     */
    public updateObject(projectId: string, visualizationUri: string, obj: any) {
        return this.xhr
            .put(`/gdc/md/${projectId}/obj/${visualizationUri}`, {
                body: obj,
            })
            .then((r: ApiResponse) => r.getData());
    }

    /**
     * LDM manage
     *
     * @experimental
     * @method ldmManage
     * @param {String} projectId
     * @param {String} maql
     * @param {Object} options for polling (maxAttempts, pollStep)
     */
    public ldmManage(projectId: string, maql: string, options = {}) {
        return this.xhr
            .post(`/gdc/md/${projectId}/ldm/manage2`, { body: { manage: { maql } } })
            .then((r: ApiResponse) => r.getData())
            .then((response: any) => {
                const manageStatusUri = response.entries[0].link;
                return handlePolling(
                    this.xhr.get.bind(this.xhr),
                    manageStatusUri,
                    this.isTaskFinished,
                    options,
                );
            })
            .then(this.checkStatusForError);
    }

    /**
     * ETL pull
     *
     * @experimental
     * @method etlPull
     * @param {String} projectId
     * @param {String} uploadsDir
     * @param {Object} options for polling (maxAttempts, pollStep)
     */
    public etlPull(projectId: string, uploadsDir: string, options = {}) {
        return this.xhr
            .post(`/gdc/md/${projectId}/etl/pull2`, { body: { pullIntegration: uploadsDir } })
            .then((r: ApiResponse) => r.getData())
            .then((response: any) => {
                const etlPullStatusUri = response.pull2Task.links.poll;
                return handlePolling(
                    this.xhr.get.bind(this.xhr),
                    etlPullStatusUri,
                    this.isTaskFinished,
                    options,
                );
            })
            .then(this.checkStatusForError);
    }

    private isTaskFinished(task: any) {
        const taskState = task.wTaskStatus.status;
        return taskState === "OK" || taskState === "ERROR";
    }

    private checkStatusForError(response: any) {
        if (response.wTaskStatus.status === "ERROR") {
            return Promise.reject(response);
        }
        return response;
    }
}
