import { AbortController } from 'abort-controller';
import { stop } from './stop.js';
import { makeAgent } from './agent.js';
import { AbortCode, RequestTimeoutError, ConnectionTimeoutError } from './req-errors.js';
import globalFetch, { Request, Headers, Response } from './fetch.js';
import * as utils from '@applitools/utils';
/**
 * Helper function that will create {@link req} function with predefined options
 * @example const req = makeReq({baseUrl: 'http://localhost:2107'})
 */
export function makeReq(baseOptions) {
    return (location, options) => req(location, mergeOptions(baseOptions, options !== null && options !== void 0 ? options : {}));
}
export async function req(input, ...requestOptions) {
    const options = mergeOptions({}, ...requestOptions);
    let abortCode;
    if (options.baseUrl && !options.baseUrl.endsWith('/'))
        options.baseUrl += '/';
    if (options.headers)
        options.headers = Object.fromEntries(Object.entries(options.headers).filter(([_, value]) => value));
    if (options.retry)
        options.retry = utils.types.isArray(options.retry) ? options.retry : [options.retry];
    if (options.hooks)
        options.hooks = utils.types.isArray(options.hooks) ? options.hooks : [options.hooks];
    const connectionController = new AbortController();
    const connectionTimer = options.connectionTimeout
        ? setTimeout(() => {
            abortCode = AbortCode.connectionTimeout;
            connectionController.abort();
        }, options.connectionTimeout)
        : null;
    try {
        return await req(input, options);
    }
    finally {
        if (connectionTimer)
            clearTimeout(connectionTimer);
    }
    async function req(input, options) {
        var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
        const url = new URL(String((_a = input.url) !== null && _a !== void 0 ? _a : input), options.baseUrl);
        const fetch = (_b = options.fetch) !== null && _b !== void 0 ? _b : globalFetch;
        let optionsFallbacks = [];
        if (options.fallbacks)
            optionsFallbacks = utils.types.isArray(options.fallbacks) ? options.fallbacks : [options.fallbacks];
        const fb = optionsFallbacks.find(fallback => { var _a; return (_a = fallback.cache) === null || _a === void 0 ? void 0 : _a.get(url.origin); });
        if (fb === null || fb === void 0 ? void 0 : fb.updateOptions)
            options = await fb.updateOptions({ options });
        const requestController = new AbortController();
        const requestTimer = options.requestTimeout
            ? setTimeout(() => {
                abortCode !== null && abortCode !== void 0 ? abortCode : (abortCode = AbortCode.requestTimeout);
                requestController.abort();
            }, options.requestTimeout)
            : null;
        if (connectionController.signal.aborted)
            requestController.abort();
        connectionController.signal.onabort = () => requestController.abort();
        if (options.signal) {
            if (options.signal.aborted)
                requestController.abort();
            options.signal.onabort = () => requestController.abort();
        }
        if (options.query) {
            Object.entries(options.query).forEach(([key, value]) => {
                if (!utils.types.isNull(value))
                    url.searchParams.set(key, String(value));
            });
        }
        const extraHeaders = {};
        if (utils.types.isPlainObject(options.body) || utils.types.isArray(options.body) || options.body === null) {
            options.body = JSON.stringify(options.body);
            extraHeaders['content-type'] = 'application/json';
        }
        let request = new Request(url, {
            method: (_c = options.method) !== null && _c !== void 0 ? _c : input.method,
            headers: {
                ...extraHeaders,
                ...Object.fromEntries((_e = (_d = input.headers) === null || _d === void 0 ? void 0 : _d.entries()) !== null && _e !== void 0 ? _e : []),
                ...Object.fromEntries(new Headers(options.headers).entries()),
            },
            body: (_f = options.body) !== null && _f !== void 0 ? _f : input.body,
            highWaterMark: 1024 * 1024 * 100 + 1,
            agent: makeAgent({
                proxy: options.proxy,
                useDnsCache: options.useDnsCache,
                keepAliveOptions: options.keepAliveOptions,
            }),
            signal: requestController.signal,
        });
        request = await beforeRequest({ request, options });
        try {
            let response = await fetch(request);
            // if the request has a fallback try it
            if (!response.ok && optionsFallbacks.length > 0) {
                const fallbackStrategy = optionsFallbacks[0];
                const shouldFallback = await fallbackStrategy.shouldFallbackCondition({ request, response });
                const fallbackOptions = shouldFallback &&
                    (await ((_g = fallbackStrategy === null || fallbackStrategy === void 0 ? void 0 : fallbackStrategy.updateOptions) === null || _g === void 0 ? void 0 : _g.call(fallbackStrategy, {
                        options: { ...options, fallbacks: optionsFallbacks.slice(1) },
                    })));
                if (fallbackOptions) {
                    const fallbackStrategyResponse = await req(request, fallbackOptions);
                    (_h = fallbackStrategy.cache) !== null && _h !== void 0 ? _h : (fallbackStrategy.cache = new Map());
                    fallbackStrategy.cache.set(new URL(request.url).origin, fallbackStrategyResponse.ok);
                    return fallbackStrategyResponse;
                }
            }
            // if the request has to be retried due to status code
            const retry = await ((_j = options.retry) === null || _j === void 0 ? void 0 : _j.reduce(async (prev, retry) => {
                var _a, _b;
                const result = await prev;
                return (result !== null && result !== void 0 ? result : ((((_a = retry.statuses) === null || _a === void 0 ? void 0 : _a.includes(response.status)) || (await ((_b = retry.validate) === null || _b === void 0 ? void 0 : _b.call(retry, { response })))) &&
                    (!retry.limit || !retry.attempt || retry.attempt < retry.limit)
                    ? retry
                    : null));
            }, Promise.resolve(null)));
            if (retry) {
                (_k = retry.attempt) !== null && _k !== void 0 ? _k : (retry.attempt = 0);
                const delay = response.headers.has('Retry-After')
                    ? Number(response.headers.get('Retry-After')) * 1000
                    : utils.types.isArray(retry.timeout)
                        ? retry.timeout[Math.min(retry.attempt, retry.timeout.length - 1)]
                        : (_l = retry.timeout) !== null && _l !== void 0 ? _l : 0;
                await utils.general.sleep(delay);
                retry.attempt += 1;
                const retryRequest = await beforeRetry({ request, response, attempt: retry.attempt, stop, options });
                if (retryRequest !== stop) {
                    return req(retryRequest, options);
                }
            }
            response = await afterResponse({ request, response, options });
            return response;
        }
        catch (error) {
            if (abortCode === AbortCode.requestTimeout)
                error = new RequestTimeoutError();
            else if (abortCode === AbortCode.connectionTimeout)
                error = new ConnectionTimeoutError();
            // if the request has to be retried due to network error
            const retry = await ((_m = options.retry) === null || _m === void 0 ? void 0 : _m.reduce((prev, retry) => {
                return prev.then(async (result) => {
                    var _a, _b;
                    return (result !== null && result !== void 0 ? result : ((((_a = retry.codes) === null || _a === void 0 ? void 0 : _a.includes(error.code)) || (await ((_b = retry.validate) === null || _b === void 0 ? void 0 : _b.call(retry, { error })))) &&
                        (!retry.limit || !retry.attempt || retry.attempt < retry.limit)))
                        ? retry
                        : null;
                });
            }, Promise.resolve(null)));
            if (retry) {
                (_o = retry.attempt) !== null && _o !== void 0 ? _o : (retry.attempt = 0);
                const delay = utils.types.isArray(retry.timeout)
                    ? retry.timeout[Math.min(retry.attempt, retry.timeout.length)]
                    : (_p = retry.timeout) !== null && _p !== void 0 ? _p : 0;
                await utils.general.sleep(delay);
                retry.attempt = retry.attempt + 1;
                const retryRequest = await beforeRetry({ request, error, attempt: retry.attempt, stop, options });
                if (retryRequest !== stop) {
                    return req(retryRequest, options);
                }
            }
            error = await afterError({ request, error, options });
            throw error;
        }
        finally {
            if (options.signal)
                options.signal.onabort = null;
            if (requestTimer)
                clearTimeout(requestTimer);
        }
    }
}
function mergeOptions(baseOptions, ...options) {
    var _a;
    const mergedOptions = options.reduce((baseOptions, options) => ({
        ...baseOptions,
        ...options,
        query: { ...baseOptions.query, ...options === null || options === void 0 ? void 0 : options.query },
        headers: { ...baseOptions.headers, ...options === null || options === void 0 ? void 0 : options.headers },
        retry: [
            ...(baseOptions.retry ? [].concat(baseOptions.retry) : []),
            ...((options === null || options === void 0 ? void 0 : options.retry) ? [].concat(options.retry) : []),
        ],
        hooks: [
            ...(baseOptions.hooks ? [].concat(baseOptions.hooks) : []),
            ...((options === null || options === void 0 ? void 0 : options.hooks) ? [].concat(options.hooks) : []),
        ],
        fallbacks: [
            ...(baseOptions.fallbacks ? [].concat(baseOptions.fallbacks) : []),
            ...((options === null || options === void 0 ? void 0 : options.fallbacks) ? [].concat(options.fallbacks) : []),
        ],
    }), baseOptions);
    return ((_a = mergedOptions.hooks) !== null && _a !== void 0 ? _a : []).reduce((options, hooks) => { var _a, _b; return (_b = (_a = hooks.afterOptionsMerged) === null || _a === void 0 ? void 0 : _a.call(hooks, { options })) !== null && _b !== void 0 ? _b : options; }, mergedOptions);
}
function beforeRequest({ request, options, ...rest }) {
    var _a;
    return ((_a = options === null || options === void 0 ? void 0 : options.hooks) !== null && _a !== void 0 ? _a : []).reduce(async (request, hooks) => {
        var _a, _b;
        request = await request;
        const result = await ((_a = hooks.beforeRequest) === null || _a === void 0 ? void 0 : _a.call(hooks, { request, options, ...rest }));
        if (!result)
            return request;
        else if (utils.types.instanceOf(result, Request))
            return result;
        else if (utils.types.has(result, 'url'))
            return new Request(result.url, (_b = result.request) !== null && _b !== void 0 ? _b : result);
        else
            return new Request(result.request, result);
    }, request);
}
function beforeRetry({ request, options, ...rest }) {
    var _a;
    return ((_a = options === null || options === void 0 ? void 0 : options.hooks) !== null && _a !== void 0 ? _a : []).reduce(async (request, hooks) => {
        var _a, _b;
        request = await request;
        if (request === stop)
            return request;
        const result = await ((_a = hooks.beforeRetry) === null || _a === void 0 ? void 0 : _a.call(hooks, { request, options, ...rest }));
        if (result === stop)
            return result;
        else if (!result)
            return request;
        else if (utils.types.instanceOf(result, Request))
            return result;
        else if (utils.types.has(result, 'url'))
            return new Request(result.url, (_b = result.request) !== null && _b !== void 0 ? _b : result);
        else
            return new Request(result.request, result);
    }, request);
}
function afterResponse({ response, options, ...rest }) {
    var _a, _b;
    return (_b = (((_a = options === null || options === void 0 ? void 0 : options.hooks) !== null && _a !== void 0 ? _a : []))) === null || _b === void 0 ? void 0 : _b.reduce(async (response, hooks) => {
        var _a, _b, _c, _d;
        response = await response;
        const result = await ((_a = hooks.afterResponse) === null || _a === void 0 ? void 0 : _a.call(hooks, { response, options, ...rest }));
        if (!result)
            return response;
        else if (utils.types.instanceOf(result, Response))
            return result;
        else
            return new Response((_b = result.body) !== null && _b !== void 0 ? _b : (_c = result.response) === null || _c === void 0 ? void 0 : _c.body, (_d = result.response) !== null && _d !== void 0 ? _d : result);
    }, response);
}
function afterError({ error, options, ...rest }) {
    var _a, _b;
    return (_b = (((_a = options === null || options === void 0 ? void 0 : options.hooks) !== null && _a !== void 0 ? _a : []))) === null || _b === void 0 ? void 0 : _b.reduce(async (error, hooks) => {
        var _a;
        error = await error;
        return (await ((_a = hooks.afterError) === null || _a === void 0 ? void 0 : _a.call(hooks, { error, options, ...rest }))) || error;
    }, error);
}
