// (C) 2007-2020 GoodData Corporation
import isPlainObject from "lodash/isPlainObject";
import isFunction from "lodash/isFunction";
import set from "lodash/set";
import defaults from "lodash/defaults";
import merge from "lodash/merge";
import result from "lodash/result";

import { thisPackage } from "./util";

/**
 * Ajax wrapper around GDC authentication mechanisms, SST and TT token handling and polling.
 * Interface is the same as original jQuery.ajax.
 *
 * If token is expired, current request is "paused", token is refreshed and request is retried and result
 * is transparently returned to the original call.
 *
 * Additionally polling is handled. Only final result of polling returned.
 * @module xhr
 * @class xhr
 */

const DEFAULT_POLL_DELAY = 1000;

const REST_API_VERSION_HEADER = "X-GDC-VERSION";
const REST_API_DEPRECATED_VERSION_HEADER = "X-GDC-DEPRECATED";

// The version used in X-GDC-VERSION header (see https://confluence.intgdc.com/display/Development/REST+API+versioning)
const LATEST_REST_API_VERSION = 5;

function simulateBeforeSend(url: string, settings: any) {
    const xhrMockInBeforeSend = {
        setRequestHeader(key: string, value: string) {
            set(settings, ["headers", key], value);
        },
    };

    if (isFunction(settings.beforeSend)) {
        settings.beforeSend(xhrMockInBeforeSend, url);
    }
}

function enrichSettingWithCustomDomain(originalUrl: string, originalSettings: any, domain: string): any {
    let url = originalUrl;
    const settings = originalSettings;
    if (domain) {
        // protect url to be prepended with domain on retry
        if (originalUrl.indexOf(domain) === -1) {
            url = domain + originalUrl;
        }
        settings.mode = "cors";
        settings.credentials = "include";
    }

    return { url, settings };
}

export function handlePolling(
    url: string,
    settings: any,
    sendRequest: (url: string, settings: any) => any,
): Promise<ApiResponse> {
    const pollingDelay: number = result(settings, "pollDelay");

    return new Promise((resolve, reject) => {
        setTimeout(() => {
            sendRequest(url, settings).then(resolve, reject);
        }, pollingDelay);
    });
}

export interface IPackageHeaders {
    name: string;
    version: string;
}

export function originPackageHeaders({ name, version }: IPackageHeaders) {
    return {
        "X-GDC-JS-PKG": name,
        "X-GDC-JS-PKG-VERSION": version,
    };
}

export class ApiError extends Error {
    constructor(message: string, public cause: any) {
        super(message);
    }
}

export class ApiResponseError extends ApiError {
    constructor(message: string, public response: any, public responseBody: any) {
        super(message, null);
    }
}

export class ApiNetworkError extends ApiError {}

export class ApiResponse {
    public response: Response;
    public responseBody: string;

    constructor(response: Response, responseBody: string) {
        this.response = response;
        this.responseBody = responseBody;
    }

    get data() {
        try {
            return JSON.parse(this.responseBody);
        } catch (error) {
            return this.responseBody;
        }
    }

    public getData() {
        try {
            return JSON.parse(this.responseBody);
        } catch (error) {
            return this.responseBody;
        }
    }

    public getHeaders() {
        return this.response;
    }
}

// the variable must be outside of the scope of the XhrModule to not log the message multiple times in SDK and KD
let shouldLogDeprecatedRestApiCall = true;

export class XhrModule {
    private tokenRequest?: any;

    constructor(private fetch: any, private configStorage: any) {
        defaults(configStorage, { xhrSettings: {} });
    }

    /**
     * Back compatible method for setting common XHR settings
     *
     * Usually in our apps we used beforeSend ajax callback to set the X-GDC-REQUEST header with unique ID.
     *
     * @param settings object XHR settings as
     */
    public ajaxSetup(settings: any) {
        Object.assign(this.configStorage.xhrSettings, settings);
    }

    public async ajax(originalUrl: string, customSettings = {}): Promise<ApiResponse> {
        // TODO refactor to: getRequestParams(originalUrl, customSettings);
        const firstSettings = this.createRequestSettings(customSettings);
        const { url, settings } = enrichSettingWithCustomDomain(
            originalUrl,
            firstSettings,
            this.configStorage.domain,
        );

        simulateBeforeSend(url, settings); // mutates `settings` param

        if (this.tokenRequest) {
            return this.continueAfterTokenRequest(url, settings);
        }

        let response;
        try {
            // TODO: We should clean up the settings at this point to be pure `RequestInit` object
            response = await this.fetch(url, settings);
        } catch (e) {
            throw new ApiNetworkError(e.message, e); // TODO is it really necessary? couldn't we throw just Error?
        }

        // Fetch URL and resolve body promise (if left unresolved, the body isn't even shown in chrome-dev-tools)
        const responseBody = await response.text();

        if (response.status === 401) {
            // if 401 is in login-request, it means wrong user/password (we wont continue)
            if (url.indexOf("/gdc/account/login") !== -1) {
                throw new ApiResponseError("Unauthorized", response, responseBody);
            }
            return this.handleUnauthorized(url, settings);
        }

        // Note: Fetch does redirects automagically for 301 (and maybe more .. TODO when?)
        // see https://fetch.spec.whatwg.org/#ref-for-concept-request%E2%91%A3%E2%91%A2
        if (response.status === 202 && !settings.dontPollOnResult) {
            // poll on new provided url, fallback to the original one
            // (for example validElements returns 303 first with new url which may then return 202 to poll on)
            let finalUrl = response.url || url;

            const finalSettings = settings;

            // if the response is 202 and Location header is not empty, let's poll on the new Location
            if (response.headers.has("Location")) {
                finalUrl = response.headers.get("Location");
            }
            finalSettings.method = "GET";
            delete finalSettings.data;
            delete finalSettings.body;

            return handlePolling(finalUrl, finalSettings, this.ajax.bind(this));
        }

        this.verifyRestApiDeprecationStatus(response.headers);

        if (response.status >= 200 && response.status <= 399) {
            return new ApiResponse(response, responseBody);
        }

        // throws on 400, 500, etc.
        throw new ApiResponseError(response.statusText, response, responseBody);
    }

    /**
     * Wrapper for xhr.ajax method GET
     */
    public get(url: string, settings?: any) {
        return this.ajax(url, merge({ method: "GET" }, settings));
    }

    /**
     * Wrapper for xhr.ajax method HEAD
     */
    public head(url: string, settings?: any) {
        return this.ajax(url, merge({ method: "HEAD" }, settings));
    }

    /**
     * Wrapper for xhr.ajax method POST
     */
    public post(url: string, settings?: any) {
        return this.ajax(url, merge({ method: "POST" }, settings));
    }

    /**
     * Wrapper for xhr.ajax method PUT
     */
    public put(url: string, settings: any) {
        return this.ajax(url, merge({ method: "PUT" }, settings));
    }

    /**
     * Wrapper for xhr.ajax method DELETE
     */
    public del(url: string, settings?: any) {
        return this.ajax(url, merge({ method: "DELETE" }, settings));
    }

    private createRequestSettings(customSettings: any) {
        const settings = merge(
            {
                headers: {
                    Accept: "application/json; charset=utf-8",
                    "Content-Type": "application/json",
                    [REST_API_VERSION_HEADER]: LATEST_REST_API_VERSION,
                    ...originPackageHeaders(this.configStorage.originPackage || thisPackage),
                },
            },
            this.configStorage.xhrSettings,
            customSettings,
        );

        settings.pollDelay = settings.pollDelay !== undefined ? settings.pollDelay : DEFAULT_POLL_DELAY;

        // TODO jquery compat - add to warnings
        settings.body = settings.data ? settings.data : settings.body;
        settings.mode = "same-origin";
        settings.credentials = "same-origin";

        if (isPlainObject(settings.body)) {
            settings.body = JSON.stringify(settings.body);
        }

        return settings;
    }

    private continueAfterTokenRequest(url: string, settings: any) {
        return this.tokenRequest.then(
            async (response: Response) => {
                if (!response.ok) {
                    throw new ApiResponseError("Unauthorized", response, null);
                }
                this.tokenRequest = null;

                return this.ajax(url, settings);
            },
            (reason: any) => {
                this.tokenRequest = null;
                return reason;
            },
        );
    }

    private async handleUnauthorized(originalUrl: string, originalSettings: any) {
        // Create only single token request for any number of waiting request.
        // If token request exist, just listen for it's end.
        if (this.tokenRequest) {
            return this.continueAfterTokenRequest(originalUrl, originalSettings);
        }

        const { url, settings } = enrichSettingWithCustomDomain(
            "/gdc/account/token",
            this.createRequestSettings({}),
            this.configStorage.domain,
        );

        this.tokenRequest = this.fetch(url, settings);
        const response = await this.tokenRequest;
        const responseBody = await response.text();
        this.tokenRequest = null;
        // TODO jquery compat - allow to attach unauthorized callback and call it if attached
        // if ((xhrObj.status === 401) && (isFunction(req.unauthorized))) {
        //     req.unauthorized(xhrObj, textStatus, err, deferred);
        //     return;
        // }
        // unauthorized handler is not defined or not http 401
        // unauthorized when retrieving token -> not logged

        if (response.status === 401) {
            throw new ApiResponseError("Unauthorized", response, responseBody);
        }

        return this.ajax(originalUrl, originalSettings);
    }

    private logDeprecatedRestApiCall(deprecatedVersionDetails: string) {
        // tslint:disable-next-line:no-console
        console.warn(
            `The REST API version ${LATEST_REST_API_VERSION} is deprecated (${deprecatedVersionDetails}). ` +
                "Please migrate your application to use GoodData.UI SDK or @gooddata/gooddata-js package that " +
                "supports newer version of the API.",
        );
    }

    private isRestApiDeprecated(responseHeaders: any) {
        return responseHeaders.has(REST_API_DEPRECATED_VERSION_HEADER);
    }

    private verifyRestApiDeprecationStatus(responseHeaders: any) {
        if (shouldLogDeprecatedRestApiCall && this.isRestApiDeprecated(responseHeaders)) {
            const deprecatedVersionDetails = responseHeaders.get(REST_API_DEPRECATED_VERSION_HEADER);
            this.logDeprecatedRestApiCall(deprecatedVersionDetails);
            shouldLogDeprecatedRestApiCall = false;
        }
    }
}
