/*!
 * V4Fire Core
 * https://github.com/V4Fire/Core
 *
 * Released under the MIT license
 * https://github.com/V4Fire/Core/blob/master/LICENSE
 */

/**
 * [[include:core/request/README.md]]
 * @packageDocumentation
 */

import { EventEmitter2 as EventEmitter } from 'eventemitter2';
import log from 'core/log';

import SyncPromise from 'core/promise/sync';
import AbortablePromise from 'core/promise/abortable';

import { isOnline } from 'core/net';
import { createControllablePromise } from 'core/promise';

import Response from 'core/request/response';
import RequestError from 'core/request/error';

import { merge } from 'core/request/helpers';
import { defaultRequestOpts, globalOpts } from 'core/request/const';

import RequestContext from 'core/request/modules/context';

import type {

	Middleware,
	CreateRequestOptions,
	RetryOptions,

	RequestPromise,
	RequestResolver,

	RequestFunctionResponse,
	RequestResponseObject

} from 'core/request/interface';

export * from 'core/request/helpers';
export * from 'core/request/interface';
export * from 'core/request/response/helpers';
export * from 'core/request/response/interface';

export { globalOpts, cache, pendingCache } from 'core/request/const';
export { default as RequestError } from 'core/request/error';
export { default as Response } from 'core/request/response';

export default request;

/**
 * Creates a new remote request with the specified options
 *
 * @param path - request path URL
 * @param opts - request options
 *
 * @example
 * ```js
 * request('bla/get').then(({data, response}) => {
 *   console.log(data, response.status);
 * });
 * ```
 */
function request<D = unknown>(path: string, opts?: CreateRequestOptions<D>): RequestPromise<D>;

/**
 * Returns a wrapped request constructor with the specified options.
 * This overload helps to organize the "builder" pattern.
 *
 * @param opts - request options
 * @example
 * ```js
 * request({okStatuses: 200})({method: 'POST'})('bla/get').then(({data, response}) => {
 *   console.log(data, response.status);
 * });
 * ```
 */
function request<D = unknown>(opts: CreateRequestOptions<D>): typeof request;

/**
 * Returns a function to create a new remote request with the specified options.
 * This overload helps to create a factory of requests.
 *
 * @param path - request path URL
 * @param resolver - function to resolve a request: it takes a request URL, request environment, and arguments
 *   from invoking the outer function and can modify some request parameters.
 *   Also, if the function returns a new string, the string will be appended to the request URL, or
 *   if the function returns a string wrapped with an array, the string fully overrides the original URL.
 *
 * @param opts - request options
 *
 * @example
 * ```js
 * // Modifying the current URL
 * request('https://foo.com', (url, env, ...args) => args.join('/'))('bla', 'baz') // https://foo.com/bla/baz
 *
 * // Replacing the current URL
 * request('https://foo.com', () => ['https://bla.com', 'bla', 'baz'])() // https://bla.com/bla/baz
 * ```
 */
function request<D = unknown, A extends any[] = unknown[]>(
	path: string,
	resolver: RequestResolver<D, A>,
	opts?: CreateRequestOptions<D>
): RequestFunctionResponse<D, A extends Array<infer V> ? V[] : unknown[]>;

function request<D = unknown>(
	path: string | CreateRequestOptions<D>,
	...args: unknown[]
): unknown {
	if (Object.isPlainObject(path)) {
		const
			defOpts = path;

		return (path, resolver, opts) => {
			if (Object.isPlainObject(path)) {
				return request(merge<CreateRequestOptions<D>>(defOpts, path));
			}

			if (Object.isFunction(resolver)) {
				return request(path, resolver, merge<CreateRequestOptions<D>>(defOpts, opts));
			}

			return request(path, merge<CreateRequestOptions<D>>(defOpts, resolver));
		};
	}

	let
		resolver,
		opts: CanUndef<CreateRequestOptions<D>>;

	if (args.length > 1) {
		[resolver, opts] = Object.cast(args);

	} else if (Object.isDictionary(args[0])) {
		opts = args[0];

	} else if (Object.isFunction(args[0])) {
		resolver = args[0];
	}

	opts ??= {};

	const
		baseCtx = new RequestContext<D>(merge(defaultRequestOpts, opts));

	const run = (...args) => {
		const emitter = new EventEmitter({
			maxListeners: 100,
			newListener: true,
			wildcard: true
		});

		const
			eventBuffer = new Set<string>();

		emitter.on('newListener', (event) => {
			if (event !== 'newListener' && event !== 'drainListeners') {
				eventBuffer.add(event);
			}
		});

		emitter.on('drainListeners', () => {
			eventBuffer.forEach((event) => emitter.emit('newListener', event));
			eventBuffer.clear();
		});

		const
			ctx = RequestContext.decorateContext(baseCtx, path, resolver, ...args),
			requestParams = ctx.params;

		const middlewareParams = {
			ctx,
			globalOpts,
			opts: requestParams
		};

		const errDetails = {
			request: requestParams
		};

		const requestPromise = new AbortablePromise(async (resolve, reject, onAbort) => {
			onAbort((err) => {
				reject(err ?? new RequestError(RequestError.Abort, errDetails));
			});

			await Promise.resolve();
			ctx.parent = requestPromise;

			if (Object.isPromise(ctx.cache)) {
				await AbortablePromise.resolve(ctx.isReady, requestPromise);
			}

			const
				middlewareTasks = <Array<CanPromise<unknown>>>[];

			Object.forEach(requestParams.middlewares, (fn: Middleware<D>) => {
				middlewareTasks.push(fn(middlewareParams));
			});

			const
				middlewareResults = await AbortablePromise.all(middlewareTasks, requestPromise),
				paramsKeyToEncode = ctx.withoutBody ? 'query' : 'body';

			// eslint-disable-next-line require-atomic-updates
			requestParams[paramsKeyToEncode] = Object.cast(await applyEncoders(requestParams[paramsKeyToEncode]));

			for (let i = 0; i < middlewareResults.length; i++) {
				// If a middleware returns a function, the function will be executed.
				// The result of invoking is provided as a result of the whole request.
				if (!Object.isFunction(middlewareResults[i])) {
					continue;
				}

				resolve((() => {
					const
						res: unknown[] = [];

					for (let j = i; j < middlewareResults.length; j++) {
						const
							el = middlewareResults[j];

						if (Object.isFunction(el)) {
							res.push(el());
						}
					}

					if (res.length === 1) {
						return res[0];
					}

					return res;
				})());

				return;
			}

			const
				url = ctx.resolveRequest(globalOpts.api),
				{cacheKey} = ctx;

			let
				fromCache = false;

			if (cacheKey != null && ctx.canCache) {
				if (ctx.pendingCache.has(cacheKey)) {
					try {
						const
							res = await ctx.pendingCache.get(cacheKey);

						if (res?.response instanceof Response) {
							resolve(res);
							return;
						}

					} catch (err) {
						const
							errType = err?.type;

						if (errType === 'clearAsync' || errType === 'abort') {
							reject(err);
							return;
						}
					}

				} else if (requestParams.engine.pendingCache !== false) {
					void ctx.pendingCache.set(cacheKey, Object.cast(createControllablePromise({
						type: AbortablePromise,
						args: [ctx.parent]
					})));
				}

				fromCache = await AbortablePromise.resolve(ctx.cache.has(cacheKey), requestPromise);
			}

			let
				resultPromise,
				cache = 'none';

			if (fromCache) {
				const getFromCache = async () => {
					cache = (await AbortablePromise.resolve(isOnline(), requestPromise)).status ? 'memory' : 'offline';
					return ctx.cache.get(cacheKey!);
				};

				resultPromise = AbortablePromise.resolveAndCall(getFromCache, requestPromise)
					.then(ctx.wrapAsResponse.bind(ctx))
					.then((res) => Object.assign(res, {cache}));

			} else {
				const reqOpts = {
					...requestParams,
					url,
					emitter,
					parent: requestPromise,
					decoders: ctx.decoders,
					streamDecoders: ctx.streamDecoders
				};

				const createEngineRequest = () => {
					const
						{engine} = requestParams;

					const
						req = engine(reqOpts, Object.cast(middlewareParams));

					return req.catch((err) => {
						if (err instanceof RequestError) {
							Object.assign(err.details, errDetails);
						}

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

				if (requestParams.retry != null) {
					const retryParams: RetryOptions = Object.isNumber(requestParams.retry) ?
						{attempts: requestParams.retry} :
						requestParams.retry;

					const
						attemptLimit = retryParams.attempts ?? Infinity,
						delayFn = retryParams.delay?.bind(retryParams) ?? ((i) => i < 5 ? i * 500 : (5).seconds());

					let
						attempt = 0;

					const createRequestWithRetrying = async () => {
						const calculateDelay = (attempt: number, err: RequestError) => {
							const
								delay = delayFn(attempt, err);

							if (Object.isPromise(delay) || delay === false) {
								return delay;
							}

							return new Promise((r) => {
								setTimeout(r, Object.isNumber(delay) ? delay : (1).second());
							});
						};

						try {
							return await createEngineRequest().then(wrapSuccessResponse);

						} catch (err) {
							if (attempt++ >= attemptLimit) {
								throw err;
							}

							const
								delay = await calculateDelay(attempt, err);

							if (delay === false) {
								throw err;
							}

							return createRequestWithRetrying();
						}
					};

					resultPromise = createRequestWithRetrying();

				} else {
					resultPromise = createEngineRequest().then(wrapSuccessResponse);
				}
			}

			resultPromise
				.then(ctx.saveCache.bind(ctx))

				.then(async ({response, data}) => {
					if (response.bodyUsed === true) {
						log(`request:response:${path}`, await data, {
							cache,
							request: requestParams
						});
					}
				})

				.catch((err) => log.error('request', err));

			resolve(ctx.wrapRequest(resultPromise));

			function applyEncoders(data: unknown): unknown {
				let
					res = AbortablePromise.resolve(data, requestPromise);

				Object.forEach(ctx.encoders, (fn, i: number) => {
					res = res.then((obj) => fn(i > 0 ? obj : Object.fastClone(obj)));
				});

				return res;
			}

			function wrapSuccessResponse(response: Response<D>): RequestResponseObject<D> {
				// eslint-disable-next-line @typescript-eslint/no-use-before-define
				void responseIterator.resolve(response[Symbol.asyncIterator].bind(response));

				const
					details = {response, ...errDetails};

				if (!response.ok) {
					throw AbortablePromise.wrapReasonToIgnore(new RequestError(RequestError.InvalidStatus, details));
				}

				let
					customData;

				return {
					ctx,
					response,

					get data() {
						return customData ?? response.decode();
					},

					set data(val: Promise<D>) {
						customData = SyncPromise.resolve(val);
					},

					get stream() {
						return response.decodeStream();
					},

					emitter,
					[Symbol.asyncIterator]: response[Symbol.asyncIterator].bind(response),

					dropCache: ctx.dropCache.bind(ctx)
				};
			}
		});

		requestPromise['emitter'] = emitter;

		void Object.defineProperty(requestPromise, 'data', {
			configurable: true,
			enumerable: true,
			get: () => requestPromise.then((res: RequestResponseObject) => res.data)
		});

		void Object.defineProperty(requestPromise, 'stream', {
			configurable: true,
			enumerable: true,
			get: () => requestPromise.then((res: RequestResponseObject) => res.stream)
		});

		const responseIterator = createControllablePromise({
			type: AbortablePromise,
			args: [ctx.parent]
		});

		requestPromise[Symbol.asyncIterator] = () => {
			const
				iter = responseIterator.then((iter: Function) => iter());

			return {
				[Symbol.asyncIterator]() {
					return this;
				},

				next() {
					return iter.then((iter) => iter.next());
				}
			};
		};

		return requestPromise;
	};

	if (Object.isFunction(resolver)) {
		return run;
	}

	return run();
}
