import { LogLevel, LogService } from "./logging/LogService";
import { getRequestFn } from "./request";
import { MatrixError } from "./models/MatrixError";

let lastRequestId = 0;

const defaultErrorHandler = (response, errBody) => {
    return typeof (errBody) === "object" && 'errcode' in errBody ?
        new MatrixError(errBody, response.statusCode, response.headers) : undefined;
};

export interface DoHttpRequestOpts {
    errorHandler?: (response, body) => Error|undefined;
}

/**
 * Performs a web request to a server.
 * @category Unit testing
 * @param {string} baseUrl The base URL to apply to the call.
 * @param {"GET"|"POST"|"PUT"|"DELETE"} method The HTTP method to use in the request
 * @param {string} endpoint The endpoint to call. For example: "/_matrix/client/v3/account/whoami"
 * @param {any} qs The query string to send. Optional.
 * @param {any} body The request body to send. Optional. Will be converted to JSON unless the type is a Buffer.
 * @param {any} headers Additional headers to send in the request.
 * @param {number} timeout The number of milliseconds to wait before timing out.
 * @param {boolean} raw If true, the raw response will be returned instead of the response body.
 * @param {string} contentType The content type to send. Only used if the `body` is a Buffer.
 * @param {string} noEncoding Set to true to disable encoding, and return a Buffer. Defaults to false
 * @returns {Promise<any>} Resolves to the response (body), rejected if a non-2xx status code was returned.
 */
export async function doHttpRequest(
    baseUrl: string,
    method: "GET" | "POST" | "PUT" | "DELETE",
    endpoint: string,
    qs = null,
    body = null,
    headers = {},
    timeout = 60000,
    raw = false,
    contentType = "application/json",
    noEncoding = false,
    opts: DoHttpRequestOpts = {
        errorHandler: defaultErrorHandler,
    },
): Promise<any> {
    if (!endpoint.startsWith('/')) {
        endpoint = '/' + endpoint;
    }

    const requestId = ++lastRequestId;
    const url = baseUrl + endpoint;

    // This is logged at info so that when a request fails people can figure out which one.
    LogService.debug("MatrixHttpClient", "(REQ-" + requestId + ")", method + " " + url);

    // Don't log the request unless we're in debug mode. It can be large.
    if (LogService.level.includes(LogLevel.TRACE)) {
        if (qs) LogService.trace("MatrixHttpClient", "(REQ-" + requestId + ")", "qs = " + JSON.stringify(qs));
        if (body && !Buffer.isBuffer(body)) LogService.trace("MatrixHttpClient", "(REQ-" + requestId + ")", "body = " + JSON.stringify(redactObjectForLogging(body)));
        if (body && Buffer.isBuffer(body)) LogService.trace("MatrixHttpClient", "(REQ-" + requestId + ")", "body = <Buffer>");
    }

    const params: { uri: string, [k: string]: any } = {
        uri: url,
        method: method,
        qs: qs,
        // If this is undefined, then a string will be returned. If it's null, a Buffer will be returned.
        encoding: noEncoding === false ? undefined : null,
        useQuerystring: true,
        qsStringifyOptions: {
            options: { arrayFormat: 'repeat' },
        },
        timeout: timeout,
        headers: headers,
        // Enable KeepAlive for HTTP
        forever: true,
    };

    if (body) {
        if (Buffer.isBuffer(body)) {
            params.headers["Content-Type"] = contentType;
            params.body = body;
        } else {
            params.headers["Content-Type"] = "application/json";
            params.body = JSON.stringify(body);
        }
    }

    const { response, resBody } = await new Promise<{ response: any, resBody: any }>((resolve, reject) => {
        getRequestFn()(params, (err, res, rBody) => {
            if (err) {
                LogService.error("MatrixHttpClient", "(REQ-" + requestId + ")", err);
                reject(err);
                return;
            }

            if (typeof (rBody) === 'string') {
                try {
                    rBody = JSON.parse(rBody);
                } catch (e) {
                }
            }

            if (typeof (res.body) === 'string') {
                try {
                    res.body = JSON.parse(res.body);
                } catch (e) {
                }
            }

            resolve({ response: res, resBody: rBody });
        });
    });

    const respIsBuffer = (response.body instanceof Buffer);

    // Check for errors.
    const errBody = response.body || resBody;
    const handledError = opts.errorHandler(response, errBody);
    if (handledError) {
        const redactedBody = respIsBuffer ? '<Buffer>' : redactObjectForLogging(errBody);
        LogService.error("MatrixHttpClient", "(REQ-" + requestId + ")", redactedBody);
        throw handledError;
    }

    // Don't log the body unless we're in debug mode. They can be large.
    if (LogService.level.includes(LogLevel.TRACE)) {
        const redactedBody = respIsBuffer ? '<Buffer>' : redactObjectForLogging(response.body);
        LogService.trace("MatrixHttpClient", "(REQ-" + requestId + " RESP-H" + response.statusCode + ")", redactedBody);
    }

    if (response.statusCode < 200 || response.statusCode >= 300) {
        const redactedBody = respIsBuffer ? '<Buffer>' : redactObjectForLogging(response.body);
        LogService.error("MatrixHttpClient", "(REQ-" + requestId + ")", redactedBody);
        throw response;
    }
    return raw ? response : resBody;
}

export function redactObjectForLogging(input: any): any {
    if (!input) return input;

    const fieldsToRedact = [
        'access_token',
        'password',
        'new_password',
    ];

    const redactFn = (i) => {
        if (!i) return i;

        // Don't treat strings like arrays/objects
        if (typeof i === 'string') return i;

        if (Array.isArray(i)) {
            const rebuilt = [];
            for (const v of i) {
                rebuilt.push(redactFn(v));
            }
            return rebuilt;
        }

        if (i instanceof Object) {
            const rebuilt = {};
            for (const key of Object.keys(i)) {
                if (fieldsToRedact.includes(key)) {
                    rebuilt[key] = '<redacted>';
                } else {
                    rebuilt[key] = redactFn(i[key]);
                }
            }
            return rebuilt;
        }

        return i; // It's a primitive value
    };

    return redactFn(input);
}
