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

import type { EventEmitter2 as EventEmitter } from 'eventemitter2';
import type { AbstractCache } from 'core/cache';

import type Data from 'core/data';
import type Range from 'core/range';
import type AbortablePromise from 'core/promise/abortable';

import type Headers from 'core/request/headers';
import type { RawHeaders } from 'core/request/headers';

import type Response from 'core/request/response';
import type { ResponseType } from 'core/request/response';

import type RequestError from 'core/request/error';
import type RequestContext from 'core/request/modules/context';

import type { defaultRequestOpts } from 'core/request/const';
import type { StatusCodes } from 'core/status-codes';
import type { ModelMethod } from 'core/data';

export type RequestMethod =
	'GET' |
	'POST' |
	'PUT' |
	'DELETE' |
	'PATCH' |
	'HEAD' |
	'CONNECT' |
	'OPTIONS' |
	'TRACE';

export type CacheStrategy =
	'queue' |
	'forever' |
	'never' |
	AbstractCache |
	Promise<AbstractCache>;

export type CacheType =
	'memory' |
	'offline';

export type RequestQuery =
	Dictionary |
	unknown[] |
	string;

export type RequestBody =
	string |
	number |
	boolean |
	Dictionary |
	FormData |
	ArrayBuffer |
	Blob;

export type NormalizedRequestBody = Exclude<
	RequestBody,
	number | boolean | Dictionary
>;

export type Statuses =
	Range<number> |
	StatusCodes |
	StatusCodes[];

export interface GlobalOptions {
	api?: Nullable<string>;
	meta: Dictionary;
}

export interface MiddlewareParams<D = unknown> {
	ctx: RequestContext<D>;
	opts: NormalizedCreateRequestOptions<D>;
	globalOpts: GlobalOptions;
}

export interface Middleware<D = unknown> {
	(params: MiddlewareParams<D>): CanPromise<any | Function>;
}

export type Middlewares<D = unknown> =
	Dictionary<Middleware<D>> |
	Iterable<Middleware<D>>;

export interface Encoder<I = unknown, O = unknown> {
	(data: I, params: MiddlewareParams): CanPromise<O>;
}

export interface WrappedEncoder<I = unknown, O = unknown> {
	(data: I): CanPromise<O>;
}

export type Encoders = Iterable<Encoder>;
export type WrappedEncoders = Iterable<WrappedEncoder>;

export interface Decoder<I = unknown, O = unknown> {
	(data: I, params: MiddlewareParams, response: Response): CanPromise<O>;
}

export interface WrappedDecoder<I = unknown, O = unknown> {
	(data: I, response: Response): CanPromise<O>;
}

export type Decoders = Iterable<Decoder>;
export type WrappedDecoders = Iterable<WrappedDecoder>;

export interface StreamDecoder<I = unknown, O = unknown> {
	(data: AnyIterable<I>, params: MiddlewareParams, response: Response): AnyIterable<O>;
}

export interface WrappedStreamDecoder<I = unknown, O = unknown> {
	(data: AnyIterable<I>, response: Response): AnyIterable<O>;
}

export type StreamDecoders = Iterable<StreamDecoder>;
export type WrappedStreamDecoders = Iterable<WrappedStreamDecoder>;

export interface RequestResponseChunk {
	loaded: number;
	total?: number;
	data?: Uint8Array;
}

export interface RequestResponseObject<D = unknown> {
	ctx: Readonly<RequestContext<D>>;
	response: Response<D>;

	data: Promise<Nullable<D>>;
	stream: AsyncIterableIterator<unknown>;

	emitter: EventEmitter;
	[Symbol.asyncIterator](): AsyncIterableIterator<RequestResponseChunk>;

	cache?: CacheType;
	dropCache(): void;
}

export type RequestResponse<D = unknown> = AbortablePromise<RequestResponseObject<D>>;

export interface RequestPromise<D = unknown> extends RequestResponse<D> {
	data: Promise<Nullable<D>>;
	stream: AsyncIterableIterator<unknown>;
	emitter: EventEmitter;
	[Symbol.asyncIterator](): AsyncIterableIterator<RequestResponseChunk>;
}

export interface RequestFunctionResponse<D = unknown, ARGS extends any[] = unknown[]> {
	(...args: ARGS extends Array<infer V> ? V[] : unknown[]): RequestPromise<D>;
}

export interface RequestResolver<D = unknown, ARGS extends any[] = unknown[]> {
	(url: string, params: MiddlewareParams<D>, ...args: ARGS): ResolverResult;
}

export type ResolverResult = CanUndef<CanArray<string>>;

export interface RequestMeta extends Dictionary {
	provider?: Data;
	providerMethod?: ModelMethod;
	providerParams?: CreateRequestOptions<any>;
}

/**
 * Options for a request
 * @typeparam D - response data type
 */
export interface CreateRequestOptions<D = unknown> {
	/**
	 * HTTP method to create a request
	 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
	 */
	method?: RequestMethod;

	/**
	 * Additional HTTP request headers.
	 * You can provide them as a simple dictionary or an instance of the Headers class.
	 * Also, you can pass headers as an instance of the `core/request/headers` class.
	 *
	 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers
	 */
	headers?: RawHeaders;

	/**
	 * Enables providing of credentials for cross-domain requests.
	 * Also, you can manage to omit any credentials if the used request engine supports it.
	 */
	credentials?: boolean | RequestCredentials;

	/**
	 * Request parameters that will be serialized to a string and passed via a request URL.
	 * To customize how to encode data to a query string, see `querySerializer`.
	 */
	query?: RequestQuery;

	/**
	 * Returns a serialized value of the specified query object
	 *
	 * @param query
	 * @example
	 * ```js
	 * import request from 'core/request';
	 * import { toQueryString } from 'core/url';
	 *
	 * request('//user', {
	 *   query: {ids: [125, 35, 454]},
	 *   querySerializer: (data) => toQueryString(data, {arraySyntax: true})
	 * }).data.then(console.log);
	 * ```
	 */
	querySerializer?(query: RequestQuery): string;

	/**
	 * Request body.
	 * Mind, not every HTTP method can send data in this way. For instance,
	 * GET or HEAD requests can send data only with URLs (@see `query`).
	 */
	body?: RequestBody;

	/**
	 * Mime type of the request data (if not specified, it will be cast dynamically)
	 *
	 * @example
	 * ```js
	 * request('//create-user', {
	 *   method: 'POST',
	 *   body: {name: 'Bob'},
	 *   contentType: 'application/x-msgpack',
	 *   encoder: toMessagePack
	 * }).data.then(console.log);
	 * ```
	 */
	contentType?: string;

	/**
	 * The data type of the response.
	 * By default, the data type is taken from the `content-type` header, and if not set, then based on this parameter.
	 * However, you can change this behavior with the `forceResponseType` parameter.
	 *
	 * 1. `'text'` - result is interpreted as a simple string;
	 * 1. `'json'` - result is interpreted as a JSON object;
	 * 1. `'document'` - result is interpreted as an XML/HTML document;
	 * 1. `'formData'` - result is interpreted as a FormData object;
	 * 1. `'blob'` - result is interpreted as a Blob object;
	 * 1. `'arrayBuffer'` - result is interpreted as a raw array buffer;
	 * 1. `'object'` - result is interpreted "as is" without any converting.
	 *
	 * @example
	 * ```js
	 * request('//users', {
	 *   responseType: 'arrayBuffer',
	 *   decoder: fromMessagePack
	 * }).data.then(console.log);
	 * ```
	 */
	responseType?: ResponseType;

	/**
	 * If true, then the `responseType` parameter takes precedence over the `content-type` header from the server
	 * @default `false`
	 */
	forceResponseType?: boolean;

	/**
	 * A list of status codes (or a single code) that match successful operation.
	 * Also, you can pass a range of codes.
	 *
	 * @default `new Range(200, 299)`
	 */
	okStatuses?: Statuses;

	/**
	 * A list of status codes (or a single code) that match response with no content.
	 * Also, you can pass a range of codes.
	 *
	 * @default `[statusCodes.NO_CONTENT, statusCodes.NOT_MODIFIED]
	 *   .concat(new Range<number>(100, 199).toArray(1))`
	 */
	noContentStatuses?: Statuses;

	/**
	 * Value in milliseconds for a request timeout
	 */
	timeout?: number;

	/**
	 * Options to retry bad requests or a number of maximum request retries
	 *
	 * @example
	 * ```js
	 * request('//users', {
	 *   timeout: (10).seconds(),
	 *   retry: 3
	 * }).data.then(console.log);
	 *
	 * request('//users', {
	 *   timeout: (10).seconds(),
	 *   retry: {
	 *     attempts: 3,
	 *     delay: (attempt) => attempt * (3).seconds()
	 *   }
	 * }).data.then(console.log);
	 * ```
	 */
	retry?: RetryOptions | number;

	/**
	 * A map of API parameters.
	 *
	 * These parameters apply if the original request URL is not absolute, and they can be used to customize the
	 * base API URL depending on the runtime environment. If you define the base API URL via
	 * `config#api` or `globalOpts.api`, these parameters will be mapped on it.
	 *
	 * @example
	 * ```js
	 * // URL (IS_PROD === true): https://foo.com/users
	 * // URL (IS_PROD === false): https://foo.com/foo-stage
	 *
	 * request('/users', {
	 *   api: {
	 *     protocol: 'https',
	 *     domain2: () => IS_PROD ? 'foo' : 'foo-stage',
	 *     zone: 'com'
	 *   }
	 * }).data.then(console.log);
	 *
	 *
	 * // URL (globalOpts.api === 'https://api.foo.com' && IS_PROD === true): https://api.foo.com/users
	 * // URL (globalOpts.api === 'https://api.foo.com' && IS_PROD === false): https://api.foo-stage.com/users
	 *
	 * request('/users', {
	 *   api: {
	 *     domain2: () => IS_PROD ? 'foo' : 'foo-stage',
	 *   }
	 * }).data.then(console.log);
	 * ```
	 */
	api?: RequestAPI;

	/**
	 * Strategy of caching for requests that support caching (by default, only GET requests can be cached):
	 *
	 * 1. `'forever'` - caches all requests and stores their values forever within the active session or
	 *   until the cache expires (if `cacheTTL` is specified);
	 * 2. `'queue'` - caches all requests, but more frequent requests will push less frequent requests;
	 * 3. `'never'` - never caches any requests;
	 * 4. Or, you can pass a custom cache object.
	 *
	 * @example
	 * ```js
	 * import request, { cache } from 'core/request';
	 * import RestrictedCache from 'core/cache/restricted';
	 *
	 * request('/users', {
	 *   cacheStrategy: 'forever'
	 * }).data.then(console.log);
	 *
	 * request('/users', {
	 *   cacheStrategy: new RestrictedCache(50)
	 * }).data.then(console.log);
	 *
	 * // If you set a strategy using string identifiers, all requests will be stored within the global cache objects.
	 * cache.forever.clear();
	 * ```
	 */
	cacheStrategy?: CacheStrategy;

	/**
	 * Value in milliseconds that indicates how long a request value should keep in the cache
	 * (all requests are stored within the active session without expiring by default)
	 */
	cacheTTL?: number;

	/**
	 * Enables support of offline caching.
	 * By default, a request can only be taken from a cache if there is no network.
	 * You can customize this logic by providing a custom cache object with the `core/cache/decorators/persistent`
	 * decorator.
	 *
	 * @default `false`
	 * @example
	 * ```js
	 * import request from 'core/request';
	 * import { asyncLocal } from 'core/kv-storage';
	 *
	 * import addPersistent from 'core/cache/decorators/persistent';
	 * import SimpleCache from 'core/cache/simple';
	 *
	 * request('/users', {
	 *   cacheStrategy: 'forever',
	 *   offlineCache: true
	 * });
	 *
	 * const
	 *   opts = {loadFromStorage: 'onInit'},
	 *   persistentCache = await addPersistent(new SimpleCache(), asyncLocal, opts);
	 *
	 * request('/users', {
	 *   cacheStrategy: persistentCache
	 * });
	 * ```
	 */
	offlineCache?: boolean;

	/**
	 * Value in milliseconds that indicates how long a request value should keep in the offline cache
	 * @default `(1).day()`
	 */
	offlineCacheTTL?: number;

	/**
	 * List of request methods that support caching
	 * @default `['GET']`
	 */
	cacheMethods?: RequestMethod[];

	/**
	 * Unique cache identifier: it can be useful to create request factories with isolated cache storages
	 */
	cacheId?: string | symbol;

	/**
	 * A dictionary or iterable value with middleware functions:
	 * functions take an environment of request parameters and can modify theirs.
	 *
	 * Please notice that the order of middleware depends on the structure you use.
	 * Also, if at least one of the middlewares returns a function, invoking this function
	 * will be returned as the request result. It can be helpful to organize mocks of data and
	 * other similar cases when you don't want to execute a real request.
	 *
	 * @example
	 * ```js
	 * request('/users', {
	 *   middlewares: {
	 *     addAPI({globalOpts}) {
	 *       if (globalOpts.api == null) {
	 *         globalOpts.api = 'https://api.foo.com';
	 *       }
	 *     },
	 *
	 *     addSession({opts}) {
	 *       opts.headers.set('Authorization', myJWT);
	 *     }
	 *   }
	 * }).data.then(console.log);
	 *
	 * // Mocking response data
	 * request('/users', {
	 *   middlewares: [
	 *     ({ctx}) => () => ctx.wrapAsResponse([
	 *       {name: 'Bob'},
	 *       {name: 'Robert'}
	 *     ])
	 *   ]
	 * });
	 * ```
	 */
	middlewares?: Middlewares<D>;

	/**
	 * A function (or a sequence of functions) takes the current request data
	 * and returns new data to request. If you provide a sequence of functions,
	 * the first function will pass a result in the next function from the sequence, etc.
	 */
	encoder?: Encoder | Encoders;

	/**
	 * A function (or a sequence of functions) takes the current request response data
	 * and returns new data to respond. If you provide a sequence of functions,
	 * the first function will pass a result to the next function from the sequence, etc.
	 */
	decoder?: Decoder | Decoders;

	/**
	 * A function (or a sequence of functions) takes the current request response data chunk
	 * and yields a new chunk to respond via an async iterator. If you provide a sequence of functions,
	 * the first function will pass a result to the next function from the sequence, etc.
	 * This parameter is used when you're parsing responses in a stream form.
	 */
	streamDecoder?: StreamDecoder | StreamDecoders;

	/**
	 * Reviver function for `JSON.parse` or false to disable defaults.
	 * By default, it parses some strings as Date instances.
	 *
	 * @default `convertIfDate`
	 */
	jsonReviver?: JSONCb | false;

	/**
	 * A dictionary with some extra parameters for the request: is usually used with middlewares to provide
	 * domain-specific information
	 */
	meta?: RequestMeta;

	/**
	 * A meta flag that indicates that the request is important: is usually used with middlewares to indicate that
	 * the request needs to be executed as soon as possible
	 *
	 * @example
	 * ```js
	 * request('/users', {
	 *   important: true,
	 *
	 *   middlewares: {
	 *     doSomeWork({ctx}) {
	 *       if (ctx.important) {
	 *         // Do some work...
	 *       }
	 *     }
	 *   }
	 * }).data.then(console.log);
	 * ```
	 */
	important?: boolean;

	/**
	 * A request engine to use.
	 * The engine - is a simple function that takes request parameters and returns an abortable promise resolved with the
	 * `core/request/response` instance. Mind, some engines provide extra features. For instance, you can listen to upload
	 * progress events with the XHR engine. Or, you can parse responses in a stream form with the Fetch engine.
	 *
	 * @example
	 * ```js
	 * import AbortablePromise from 'core/promise/abortable';
	 *
	 * import request from 'core/request';
	 * import Response from 'core/request/response';
	 *
	 * import fetchEngine from 'core/request/engines/fetch';
	 * import xhrEngine from 'core/request/engines/xhr';
	 *
	 * request('//users', {
	 *   engine: fetchEngine,
	 *   credentials: 'omit'
	 * }).data.then(console.log);
	 *
	 * request('//users', {
	 *   engine: xhrEngine
	 * }).data.then(console.log);
	 *
	 * request('//users', {
	 *   engine: (params) => new AbortablePromise((resolve) => {
	 *     const res = new Response({
	 *       message: 'Hello world'
	 *     }, {responseType: 'object'});
	 *
	 *     resolve(res);
	 *
	 *   }, params.parent)
	 *
	 * }).data.then(console.log);
	 * ```
	 */
	engine?: RequestEngine;
}

/**
 * Options to retry bad requests
 * @typeparam D - response data type
 */
export interface RetryOptions<D = unknown> {
	/**
	 * Maximum number of attempts to request
	 */
	attempts?: number;

	/**
	 * Returns a number in milliseconds (or a promise) to wait before the next attempt.
	 * If the function returns false, it will prevent all further attempts.
	 *
	 * @param attempt - current attempt number
	 * @param error - error object
	 */
	delay?(attempt: number, error: RequestError<D>): number | Promise<void> | false;
}

export type RequestAPIValue<T = string> = Nullable<T> | (() => Nullable<T>);

/**
 * A map of API parameters.
 *
 * These parameters apply if the original request URL is not absolute, and they can be used to customize the
 * base API URL depending on the runtime environment. If you define the base API URL via
 * `config#api` or `globalOpts.api`, these parameters will be mapped on it.
 */
export interface RequestAPI {
	/**
	 * The direct value of API URL.
	 * If this parameter is defined, all other parameters will be ignored.
	 *
	 * @example
	 * `'https://google.com'`
	 */
	url?: RequestAPIValue;

	/**
	 * API protocol
	 *
	 * @example
	 * `'http'`
	 * `'https'`
	 */
	protocol?: RequestAPIValue;

	/**
	 * Value for an API authorization part
	 *
	 * @example
	 * `'login:password'`
	 */
	auth?: RequestAPIValue;

	/**
	 * Value for an API domain level 6 part
	 */
	domain6?: RequestAPIValue;

	/**
	 * Value for an API domain level 5 part
	 */
	domain5?: RequestAPIValue;

	/**
	 * Value for an API domain level 4 part
	 */
	domain4?: RequestAPIValue;

	/**
	 * Value for an API domain level 3 part
	 */
	domain3?: RequestAPIValue;

	/**
	 * Value for an API domain level 2 part
	 */
	domain2?: RequestAPIValue;

	/**
	 * Value for an API domain zone part
	 */
	zone?: RequestAPIValue;

	/**
	 * Value for an API api port
	 */
	port?: RequestAPIValue<string | number>;

	/**
	 * Value for an API namespace part: it follows after '/' character
	 */
	namespace?: RequestAPIValue;
}

// @ts-ignore (extend)
export interface WrappedCreateRequestOptions<D = unknown> extends CreateRequestOptions<D> {
	/**
	 * URL to make request
	 */
	url: CanUndef<string>;

	/**
	 * Original path that was passed into the request function
	 */
	path: CanUndef<string>;

	headers: Headers;
	encoder?: WrappedEncoder | WrappedEncoders;
	decoder?: WrappedDecoder | WrappedDecoders;
	streamDecoder?: WrappedStreamDecoder | WrappedStreamDecoders;
}

export type NormalizedCreateRequestOptions<D = unknown> = typeof defaultRequestOpts & WrappedCreateRequestOptions<D>;

export interface RequestOptions {
	readonly url: string;
	readonly method: RequestMethod;

	readonly emitter: EventEmitter;
	readonly parent: AbortablePromise;

	readonly timeout?: number;
	readonly okStatuses?: Statuses;
	readonly noContentStatuses?: Statuses;

	readonly contentType?: string;
	readonly responseType?: ResponseType;
	readonly forceResponseType?: boolean;

	readonly decoders?: WrappedDecoders;
	readonly streamDecoders?: WrappedStreamDecoders;
	readonly jsonReviver?: JSONCb | false;

	readonly meta?: RequestMeta;
	readonly headers?: Headers;
	readonly body?: RequestBody;

	readonly important?: boolean;
	readonly credentials?: boolean | RequestCredentials;
}

/**
 * Request engine
 */
export interface RequestEngine {
	(request: RequestOptions, params: MiddlewareParams): AbortablePromise<Response>;

	/**
	 * A flag indicates that the active requests with the same request hash can be merged
	 * @default `true`
	 */
	pendingCache?: boolean;
}
