/* eslint-disable max-lines */

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

/**
 * [[include:super/i-data/README.md]]
 * @packageDocumentation
 */

import symbolGenerator from 'core/symbol';
import SyncPromise from 'core/promise/sync';

import { deprecate, deprecated } from 'core/functools/deprecation';

import RequestError from 'core/request/error';
import { providers } from 'core/data/const';

//#if runtime has core/data

import type Provider from 'core/data';
import type {

	RequestQuery,
	RequestBody,
	RequestResponseObject,

	ModelMethod,
	ProviderOptions

} from 'core/data';

//#endif

import type Async from 'core/async';
import type { AsyncOptions } from 'core/async';

import iProgress from 'traits/i-progress/i-progress';

import iBlock, {

	component,
	wrapEventEmitter,

	prop,
	field,
	system,
	watch,
	wait,

	ReadonlyEventEmitterWrapper,

	InitLoadCb,
	InitLoadOptions,

	ModsDecl,
	UnsafeGetter

} from 'super/i-block/i-block';

import { providerMethods } from 'super/i-data/const';

import type {

	UnsafeIData,

	RequestParams,
	DefaultRequest,
	RequestFilter,
	CreateRequestOptions,

	RetryRequestFn,
	ComponentConverter,
	CheckDBEquality

} from 'super/i-data/interface';

export { RequestError };

//#if runtime has core/data

export {

	Socket,
	RequestQuery,
	RequestBody,
	RequestResponseObject,
	Response,
	ModelMethod,
	ProviderOptions,
	ExtraProvider,
	ExtraProviders

} from 'core/data';

//#endif

export * from 'super/i-block/i-block';
export * from 'super/i-data/const';
export * from 'super/i-data/interface';

export const
	$$ = symbolGenerator();

/**
 * Superclass for all components that need to download data from data providers
 */
@component({functional: null})
export default abstract class iData extends iBlock implements iProgress {
	/**
	 * Type: raw provider data
	 */
	readonly DB!: object;

	//#if runtime has iData

	/**
	 * Data provider name
	 */
	@prop({type: String, required: false})
	readonly dataProvider?: string;

	/**
	 * Initial parameters for the data provider instance
	 */
	@prop({type: Object, required: false})
	readonly dataProviderOptions?: ProviderOptions;

	/**
	 * External request parameters.
	 * Keys of the object represent names of data provider methods.
	 * Parameters that associated with provider methods will be automatically appended to
	 * invocation as parameters by default.
	 *
	 * This parameter is useful to provide some request parameters from a parent component.
	 *
	 * @example
	 * ```
	 * < b-select :dataProvider = 'Cities' | :request = {get: {text: searchValue}}
	 *
	 * // Also, you can provide additional parameters to request method
	 * < b-select :dataProvider = 'Cities' | :request = {get: [{text: searchValue}, {cacheStrategy: 'never'}]}
	 * ```
	 */
	@prop({type: [Object, Array], required: false})
	readonly request?: RequestParams;

	/**
	 * Remote data converter/s.
	 * This function (or a list of functions) transforms initial provider data before saving to `db`.
	 */
	@prop({type: [Function, Array], required: false})
	readonly dbConverter?: CanArray<ComponentConverter>;

	/**
	 * Converter/s from the raw `db` to the component fields
	 */
	@prop({type: [Function, Array], required: false})
	readonly componentConverter?: CanArray<ComponentConverter>;

	/**
	 * A function to filter all "default" requests, i.e., all requests that were produced implicitly,
	 * like an initial component request or requests that are triggered by changing of parameters from
	 * `request` and `requestParams`. If the filter returns negative value, the tied request will be aborted.
	 *
	 * Also, you can set this parameter to true, and it will filter only requests with a payload.
	 */
	@prop({type: [Boolean, Function], required: false})
	readonly defaultRequestFilter?: RequestFilter;

	/**
	 * @deprecated
	 * @see [[iData.defaultRequestFilter]]
	 */
	@prop({type: [Boolean, Function], required: false})
	readonly requestFilter?: RequestFilter;

	/**
	 * If true, all requests to the data provider are suspended till you don't manually force it.
	 * This prop is used when you want to organize the lazy loading of components.
	 * For instance, you can load only components in the viewport.
	 */
	@prop(Boolean)
	readonly suspendRequestsProp: boolean = false;

	/**
	 * Enables the suspending of all requests to the data provider till you don't manually force it.
	 * Also, the parameter can contain a promise resolve function.
	 * @see [[iData.suspendRequestsProp]]
	 */
	@system((o) => o.sync.link())
	suspendRequests?: boolean | Function;

	/**
	 * If true, then the component can reload data within the offline mode
	 */
	@prop(Boolean)
	readonly offlineReload: boolean = false;

	/**
	 * If true, then all new initial provider data will be compared with the old data.
	 * Also, the parameter can be passed as a function, that returns true if data are equal.
	 */
	@prop({type: [Boolean, Function]})
	readonly checkDBEquality: CheckDBEquality = true;

	override get unsafe(): UnsafeGetter<UnsafeIData<this>> {
		return Object.cast(this);
	}

	/**
	 * Initial component data.
	 * When a component takes data from own data provider it stores the value within this property.
	 */
	get db(): CanUndef<this['DB']> {
		return this.field.get('dbStore');
	}

	/**
	 * Sets new component data
	 *
	 * @emits `dbCanChange(value: CanUndef<this['DB']>)`
	 * @emits `dbChange(value: CanUndef<this['DB']>)`
	 */
	set db(value: CanUndef<this['DB']>) {
		this.emit('dbCanChange', value);

		if (value === this.db) {
			return;
		}

		const
			{async: $a} = this;

		$a.terminateWorker({
			label: $$.db
		});

		this.field.set('dbStore', value);

		if (this.initRemoteData() !== undefined) {
			this.watch('dbStore', this.initRemoteData.bind(this), {
				deep: true,
				label: $$.db
			});
		}

		this.emit('dbChange', value);
	}

	static override readonly mods: ModsDecl = {
		...iProgress.mods
	};

	/**
	 * Event emitter of a component data provider
	 */
	@system<iData>({
		atom: true,
		after: 'async',
		unique: true,
		init: (o, d) => wrapEventEmitter(<Async>d.async, () => o.dp?.emitter, true)
	})

	protected readonly dataEmitter!: ReadonlyEventEmitterWrapper<this>;

	/**
	 * @deprecated
	 * @see [[iData.dataEmitter]]
	 */
	@deprecated({renamedTo: 'dataEmitter'})
	get dataEvent(): ReadonlyEventEmitterWrapper<this> {
		return this.dataEmitter;
	}

	/**
	 * Request parameters for the data provider.
	 * Keys of the object represent names of data provider methods.
	 * Parameters that associated with provider methods will be automatically appended to
	 * invocation as parameters by default.
	 *
	 * To create logic when the data provider automatically reload data, if some properties has been
	 * changed, you need to use 'sync.object'.
	 *
	 * @example
	 * ```ts
	 * class Foo extends iData {
	 *   @system()
	 *   i: number = 0;
	 *
	 *   // {get: {step: 0}, upd: {i: 0}, del: {i: '0'}}
	 *   @system((ctx) => ({
	 *     ...ctx.sync.link('get', [
	 *       ['step', 'i']
	 *     ]),
	 *
	 *     ...ctx.sync.link('upd', [
	 *       ['i']
	 *     ]),
	 *
	 *     ...ctx.sync.link('del', [
	 *       ['i', String]
	 *     ]),
	 *   })
	 *
	 *   protected readonly requestParams!: RequestParams;
	 * }
	 * ```
	 */
	@system({merge: true})
	protected readonly requestParams: RequestParams = {get: {}};

	/**
	 * Component data store
	 * @see [[iData.db]]
	 */
	@field()
	// @ts-ignore (extend)
	protected dbStore?: CanUndef<this['DB']>;

	/**
	 * Instance of a component data provider
	 */
	@system()
	protected dp?: Provider;

	/**
	 * Unsuspend requests to the data provider
	 */
	unsuspendRequests(): void {
		if (Object.isFunction(this.suspendRequests)) {
			this.suspendRequests();
		}
	}

	override initLoad(data?: unknown, opts: InitLoadOptions = {}): CanPromise<void> {
		if (!this.isActivated) {
			return;
		}

		const
			{async: $a} = this;

		const label = <AsyncOptions>{
			label: $$.initLoad,
			join: 'replace'
		};

		const
			callSuper = () => super.initLoad(() => this.db, opts);

		try {
			if (opts.emitStartEvent !== false) {
				this.emit('initLoadStart', opts);
			}

			opts = {
				emitStartEvent: false,
				...opts
			};

			$a
				.clearAll({group: 'requestSync:get'});

			if (this.isNotRegular && !this.isSSR) {
				const res = super.initLoad(() => {
					if (data !== undefined) {
						this.db = this.convertDataToDB<this['DB']>(data);
					}

					return this.db;
				}, opts);

				if (Object.isPromise(res)) {
					this.$initializer = res;
				}

				return res;
			}

			if (this.dataProvider != null && this.dp == null) {
				this.syncDataProviderWatcher(false);
			}

			if (!opts.silent) {
				this.componentStatus = 'loading';
			}

			if (data !== undefined) {
				const db = this.convertDataToDB<this['DB']>(data);
				void this.lfc.execCbAtTheRightTime(() => this.db = db, label);

			} else if (this.dp?.baseURL != null) {
				const
					needRequest = Object.isArray(this.getDefaultRequestParams('get'));

				if (needRequest) {
					const res = $a
						.nextTick(label)

						.then(() => {
							const
								defParams = this.getDefaultRequestParams<this['DB']>('get');

							if (defParams == null) {
								return;
							}

							const
								query = defParams[0],
								opts = {
									...defParams[1],
									...label,
									important: this.componentStatus === 'unloaded'
								};

							// Prefetch
							void this.moduleLoader.load(...this.dependencies);
							void this.state.initFromStorage();

							return this.get(<RequestQuery>query, opts);
						})

						.then(
							(data) => {
								void this.lfc.execCbAtTheRightTime(() => {
									this.saveDataToRootStore(data);
									this.db = this.convertDataToDB<this['DB']>(data);
								}, label);

								return callSuper();
							},

							(err) => {
								stderr(err);
								return callSuper();
							}
						);

					this.$initializer = res;
					return res;
				}

				if (this.db !== undefined) {
					void this.lfc.execCbAtTheRightTime(() => this.db = undefined, label);
				}
			}

			return callSuper();

		} catch (err) {
			stderr(err);
			return callSuper();
		}
	}

	/**
	 * Link to `iBlock.initLoad`
	 *
	 * @see [[iBlock.initLoad]]
	 * @param [data]
	 * @param [opts]
	 */
	initBaseLoad(data?: unknown | InitLoadCb, opts?: InitLoadOptions): CanPromise<void> {
		return super.initLoad(data, opts);
	}

	override reload(opts?: InitLoadOptions): Promise<void> {
		if (!this.r.isOnline && !this.offlineReload) {
			return Promise.resolve();
		}

		return super.reload(opts);
	}

	/**
	 * Drops the data provider cache
	 */
	dropDataCache(): void {
		this.dp?.dropCache();
	}

	/**
	 * @deprecated
	 * @see [[iData.dropDataCache]]
	 */
	@deprecated({renamedTo: 'dropProviderCache'})
	dropRequestCache(): void {
		this.dropDataCache();
	}

	/**
	 * Returns the full URL of any request
	 */
	url(): CanUndef<string>;

	/**
	 * Sets an extra URL part for any request (it is concatenated with the base part of URL).
	 * This method returns a new component object with additional context.
	 *
	 * @param [value]
	 *
	 * @example
	 * ```js
	 * this.url('list').get()
	 * ```
	 */
	url(value: string): this;
	url(value?: string): CanUndef<string> | this {
		if (value == null) {
			return this.dp?.url();
		}

		if (this.dp != null) {
			const ctx = Object.create(this);
			ctx.dp = this.dp.url(value);
			return this.patchProviderContext(ctx);
		}

		return this;
	}

	/**
	 * Returns the base part of URL of any request
	 */
	base(): CanUndef<string>;

	/**
	 * Sets the base part of URL for any request.
	 * This method returns a new component object with additional context.
	 *
	 * @param [value]
	 *
	 * @example
	 * ```js
	 * this.base('list').get()
	 * ```
	 */
	base(value: string): this;
	base(value?: string): CanUndef<string> | this {
		if (value == null) {
			return this.dp?.base();
		}

		if (this.dp != null) {
			const ctx = Object.create(this);
			ctx.dp = this.dp.base(value);
			return this.patchProviderContext(ctx);
		}

		return this;
	}

	/**
	 * Requests the provider for data by a query.
	 * This method is similar for a GET request.
	 *
	 * @see [[Provider.get]]
	 * @param [query] - request query
	 * @param [opts] - additional request options
	 */
	get<D = unknown>(query?: RequestQuery, opts?: CreateRequestOptions<D>): Promise<CanUndef<D>> {
		const
			args = arguments.length > 0 ? [query, opts] : this.getDefaultRequestParams<D>('get');

		if (Object.isArray(args)) {
			return this.createRequest('get', ...Object.cast<[RequestQuery, CreateRequestOptions<D>]>(args));
		}

		return Promise.resolve(undefined);
	}

	/**
	 * Checks accessibility of the provider by a query.
	 * This method is similar for a HEAD request.
	 *
	 * @see [[Provider.peek]]
	 * @param [query] - request query
	 * @param [opts] - additional request options
	 */
	peek<D = unknown>(query?: RequestQuery, opts?: CreateRequestOptions<D>): Promise<CanUndef<D>> {
		const
			args = arguments.length > 0 ? [query, opts] : this.getDefaultRequestParams('peek');

		if (Object.isArray(args)) {
			return this.createRequest('peek', ...Object.cast<[RequestQuery, CreateRequestOptions<D>]>(args));
		}

		return Promise.resolve(undefined);
	}

	/**
	 * Sends custom data to the provider without any logically effect.
	 * This method is similar for a POST request.
	 *
	 * @see [[Provider.post]]
	 * @param [body] - request body
	 * @param [opts] - additional request options
	 */
	post<D = unknown>(body?: RequestBody, opts?: CreateRequestOptions<D>): Promise<CanUndef<D>> {
		const
			args = arguments.length > 0 ? [body, opts] : this.getDefaultRequestParams('post');

		if (Object.isArray(args)) {
			return this.createRequest('post', ...Object.cast<[RequestBody, CreateRequestOptions<D>]>(args));
		}

		return Promise.resolve(undefined);
	}

	/**
	 * Adds new data to the provider.
	 * This method is similar for a POST request.
	 *
	 * @see [[Provider.add]]
	 * @param [body] - request body
	 * @param [opts] - additional request options
	 */
	add<D = unknown>(body?: RequestBody, opts?: CreateRequestOptions<D>): Promise<CanUndef<D>> {
		const
			args = arguments.length > 0 ? [body, opts] : this.getDefaultRequestParams('add');

		if (Object.isArray(args)) {
			return this.createRequest('add', ...Object.cast<[RequestBody, CreateRequestOptions<D>]>(args));
		}

		return Promise.resolve(undefined);
	}

	/**
	 * Updates data of the provider by a query.
	 * This method is similar for PUT or PATCH requests.
	 *
	 * @see [[Provider.upd]]
	 * @param [body] - request body
	 * @param [opts] - additional request options
	 */
	upd<D = unknown>(body?: RequestBody, opts?: CreateRequestOptions<D>): Promise<CanUndef<D>> {
		const
			args = arguments.length > 0 ? [body, opts] : this.getDefaultRequestParams('upd');

		if (Object.isArray(args)) {
			return this.createRequest('upd', ...Object.cast<[RequestBody, CreateRequestOptions<D>]>(args));
		}

		return Promise.resolve(undefined);
	}

	/**
	 * Deletes data of the provider by a query.
	 * This method is similar for a DELETE request.
	 *
	 * @see [[Provider.del]]
	 * @param [body] - request body
	 * @param [opts] - additional request options
	 */
	del<D = unknown>(body?: RequestBody, opts?: CreateRequestOptions<D>): Promise<CanUndef<D>> {
		const
			args = arguments.length > 0 ? [body, opts] : this.getDefaultRequestParams('del');

		if (Object.isArray(args)) {
			return this.createRequest('del', ...Object.cast<[RequestBody, CreateRequestOptions<D>]>(args));
		}

		return Promise.resolve(undefined);
	}

	/**
	 * Saves data to the root data store.
	 * All components with specified global names or data providers by default store data from initial providers'
	 * requests with the root component.
	 *
	 * You can check each provider data by using `r.providerDataStore`.
	 *
	 * @param data
	 * @param [key] - key to save data
	 */
	protected saveDataToRootStore(data: unknown, key?: string): void {
		key ??= this.globalName ?? this.dataProvider;

		if (key == null) {
			return;
		}

		this.r.providerDataStore.set(key, data);
	}

	/**
	 * Converts raw provider data to the component `db` format and returns it
	 * @param data
	 */
	protected convertDataToDB<O>(data: unknown): O;
	protected convertDataToDB(data: unknown): this['DB'];
	protected convertDataToDB<O>(data: unknown): O | this['DB'] {
		let
			val = data;

		if (this.dbConverter != null) {
			const
				converters = Array.concat([], this.dbConverter);

			if (converters.length > 0) {
				val = Object.isArray(val) || Object.isDictionary(val) ? val.valueOf() : val;

				for (let i = 0; i < converters.length; i++) {
					val = converters[i].call(this, val, this);
				}
			}
		}

		const
			{db, checkDBEquality} = this;

		const canKeepOldData = Object.isFunction(checkDBEquality) ?
			Object.isTruly(checkDBEquality.call(this, val, db)) :
			checkDBEquality && Object.fastCompare(val, db);

		if (canKeepOldData) {
			return <O | this['DB']>db;
		}

		return <O | this['DB']>val;
	}

	/**
	 * Converts data from `db` to the component field format and returns it
	 * @param data
	 */
	protected convertDBToComponent<O = unknown>(data: unknown): O | this['DB'] {
		let
			val = data;

		if (this.componentConverter) {
			const
				converters = Array.concat([], this.componentConverter);

			if (converters.length > 0) {
				val = Object.isArray(val) || Object.isDictionary(val) ? val.valueOf() : val;

				for (let i = 0; i < converters.length; i++) {
					val = converters[i].call(this, val, this);
				}
			}
		}

		return <O | this['DB']>val;
	}

	/**
	 * Initializes component data from the data provider.
	 * This method is used to map `db` to component properties.
	 * If the method is used, it must return some value that not equals to undefined.
	 */
	@watch('componentConverter')
	protected initRemoteData(): CanUndef<unknown> {
		return undefined;
	}

	protected override initGlobalEvents(resetListener?: boolean): void {
		super.initGlobalEvents(resetListener != null ? resetListener : Boolean(this.dataProvider));
	}

	/**
	 * Initializes data event listeners
	 */
	@wait('ready', {label: $$.initDataListeners})
	protected initDataListeners(): void {
		const {
			dataEmitter: $e
		} = this;

		const group = {group: 'dataProviderSync'};
		$e.off(group);

		$e.on('add', async (data) => {
			if (this.getDefaultRequestParams('get')) {
				this.onAddData(await (Object.isFunction(data) ? data() : data));
			}
		}, group);

		$e.on('upd', async (data) => {
			if (this.getDefaultRequestParams('get')) {
				this.onUpdData(await (Object.isFunction(data) ? data() : data));
			}
		}, group);

		$e.on('del', async (data) => {
			if (this.getDefaultRequestParams('get')) {
				this.onDelData(await (Object.isFunction(data) ? data() : data));
			}
		}, group);

		$e.on('refresh', async (data) => {
			await this.onRefreshData(await (Object.isFunction(data) ? data() : data));
		}, group);
	}

	/**
	 * Synchronization of request fields
	 *
	 * @param [value]
	 * @param [oldValue]
	 */
	protected syncRequestParamsWatcher<T = unknown>(
		value?: RequestParams<T>,
		oldValue?: RequestParams<T>
	): void {
		if (!value) {
			return;
		}

		const
			{async: $a} = this;

		for (let o = Object.keys(value), i = 0; i < o.length; i++) {
			const
				key = o[i],
				val = value[key],
				oldVal = oldValue?.[key];

			if (val != null && oldVal != null && Object.fastCompare(val, oldVal)) {
				continue;
			}

			const
				m = key.split(':', 1)[0],
				group = {group: `requestSync:${m}`};

			$a
				.clearAll(group);

			if (m === 'get') {
				this.componentStatus = 'loading';
				$a.setImmediate(this.initLoad.bind(this), group);

			} else {
				$a.setImmediate(() => this[m](...this.getDefaultRequestParams(key) ?? []), group);
			}
		}
	}

	/**
	 * Synchronization of `dataProvider` properties
	 * @param [initLoad] - if false, there is no need to call `initLoad`
	 */
	@watch([
		{field: 'dataProvider', provideArgs: false},
		{field: 'dataProviderOptions', provideArgs: false}
	])

	protected syncDataProviderWatcher(initLoad: boolean = true): void {
		const
			provider = this.dataProvider;

		if (this.dp) {
			this.async
				.clearAll({group: /requestSync/})
				.clearAll({label: $$.initLoad});

			this.dataEmitter.off();
			this.dp = undefined;
		}

		if (provider != null) {
			const
				ProviderConstructor = <CanUndef<typeof Provider>>providers[provider];

			if (ProviderConstructor == null) {
				if (provider === 'Provider') {
					return;
				}

				throw new ReferenceError(`The provider "${provider}" is not defined`);
			}

			const watchParams = {
				deep: true,
				group: 'requestSync'
			};

			this.watch('request', watchParams, this.syncRequestParamsWatcher.bind(this));
			this.watch('requestParams', watchParams, this.syncRequestParamsWatcher.bind(this));

			this.dp = new ProviderConstructor(this.dataProviderOptions);
			this.initDataListeners();

			if (initLoad) {
				void this.initLoad();
			}
		}
	}

	/**
	 * Returns default request parameters for the specified data provider method
	 * @param method
	 */
	protected getDefaultRequestParams<T = unknown>(method: string): CanUndef<DefaultRequest<T>> {
		const
			{field} = this;

		const
			[customData, customOpts] = Array.concat([], field.get(`request.${method}`));

		const
			p = field.get(`requestParams.${method}`),
			isGet = /^get(:|$)/.test(method);

		let
			res;

		if (Object.isArray(p)) {
			p[1] = p[1] ?? {};
			res = p;

		} else {
			res = [p, {}];
		}

		if (Object.isPlainObject(res[0]) && Object.isPlainObject(customData)) {
			res[0] = Object.mixin({
				onlyNew: true,
				filter: (el) => {
					if (isGet) {
						return el != null;
					}

					return el !== undefined;
				}
			}, undefined, res[0], customData);

		} else {
			res[0] = res[0] != null ? res[0] : customData;
		}

		res[1] = Object.mixin({deep: true}, undefined, res[1], customOpts);

		const
			requestFilter = this.requestFilter ?? this.defaultRequestFilter,
			isEmpty = Object.size(res[0]) === 0;

		const info = {
			isEmpty,
			method,
			params: res[1]
		};

		let
			needSkip = false;

		if (this.requestFilter != null) {
			deprecate({
				name: 'requestFilter',
				type: 'property',
				alternative: {name: 'defaultRequestFilter'}
			});

			if (Object.isFunction(requestFilter)) {
				needSkip = !Object.isTruly(requestFilter.call(this, res[0], info));

			} else if (requestFilter === false) {
				needSkip = isEmpty;
			}

		} else if (Object.isFunction(requestFilter)) {
			needSkip = !Object.isTruly(requestFilter.call(this, res[0], info));

		} else if (requestFilter === true) {
			needSkip = isEmpty;
		}

		if (needSkip) {
			return;
		}

		return res;
	}

	/**
	 * Returns a promise that will be resolved when the component can produce requests to the data provider
	 */
	protected waitPermissionToRequest(): Promise<boolean> {
		if (this.suspendRequests === false) {
			return SyncPromise.resolve(true);
		}

		return this.async.promise(() => new Promise((resolve) => {
			this.suspendRequests = () => {
				resolve(true);
				this.suspendRequests = false;
			};

		}), {
			label: $$.waitPermissionToRequest,
			join: true
		});
	}

	/**
	 * Patches the specified component context with the provider' CRUD methods
	 * @param ctx
	 */
	protected patchProviderContext(ctx: this): this {
		for (let i = 0; i < providerMethods.length; i++) {
			const
				method = providerMethods[i];

			Object.defineProperty(ctx, method, {
				writable: true,
				configurable: true,
				value: this.instance[method]
			});
		}

		return ctx;
	}

	/**
	 * Creates a new request to the data provider
	 *
	 * @param method - request method
	 * @param [body] - request body
	 * @param [opts] - additional options
	 */
	protected createRequest<D = unknown>(
		method: ModelMethod | Provider[ModelMethod],
		body?: RequestQuery | RequestBody,
		opts: CreateRequestOptions<D> = {}
	): Promise<CanUndef<D>> {
		if (!this.dp) {
			return Promise.resolve(undefined);
		}

		const
			asyncFields = ['join', 'label', 'group'],
			reqParams = Object.reject(opts, asyncFields),
			asyncParams = Object.select(opts, asyncFields);

		const req = this.waitPermissionToRequest()
			.then(() => {
				let
					rawRequest;

				if (Object.isFunction(method)) {
					rawRequest = method(Object.cast(body), reqParams);

				} else {
					if (this.dp == null) {
						throw new ReferenceError('The data provider to request is not defined');
					}

					rawRequest = this.dp[method](Object.cast(body), reqParams);
				}

				return this.async.request<RequestResponseObject<D>>(rawRequest, asyncParams);
			});

		if (this.mods.progress !== 'true') {
			const
				is = (v) => v !== false;

			if (is(opts.showProgress)) {
				void this.setMod('progress', true);
			}

			const then = () => {
				if (is(opts.hideProgress)) {
					void this.lfc.execCbAtTheRightTime(() => this.setMod('progress', false));
				}
			};

			req.then(then, (err) => {
				this.onRequestError(err, () => this.createRequest<D>(method, body, opts));
				then();
			});
		}

		return req.then((res) => res.data).then((data) => data ?? undefined);
	}

	/**
	 * Handler: `dataProvider.error`
	 *
	 * @param err
	 * @param retry - retry function
	 * @emits `requestError(err: Error |` [[RequestError]], retry:` [[RetryRequestFn]]`)`
	 */
	protected onRequestError(err: Error | RequestError, retry: RetryRequestFn): void {
		this.emitError('requestError', err, retry);
	}

	/**
	 * Handler: `dataProvider.add`
	 * @param data
	 */
	protected onAddData(data: unknown): void {
		if (data != null) {
			this.db = this.convertDataToDB(data);

		} else {
			this.reload().catch(stderr);
		}
	}

	/**
	 * Handler: `dataProvider.upd`
	 * @param data
	 */
	protected onUpdData(data: unknown): void {
		if (data != null) {
			this.db = this.convertDataToDB(data);

		} else {
			this.reload().catch(stderr);
		}
	}

	/**
	 * Handler: `dataProvider.del`
	 * @param data
	 */
	protected onDelData(data: unknown): void {
		if (data != null) {
			this.db = this.convertDataToDB(data);

		} else {
			this.reload().catch(stderr);
		}
	}

	/**
	 * Handler: `dataProvider.refresh`
	 * @param data
	 */
	// eslint-disable-next-line @typescript-eslint/no-unused-vars-experimental
	protected onRefreshData(data: this['DB']): Promise<void> {
		return this.reload();
	}

	//#endif
}
