/**
 * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
 * Node module: @schukai/monster
 *
 * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
 * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
 *
 * For those who do not wish to adhere to the AGPLv3, a commercial license is available.
 * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
 * For more information about purchasing a commercial license, please contact Volker Schukai.
 *
 * SPDX-License-Identifier: AGPL-3.0
 */

import { internalSymbol, instanceSymbol } from "../../../constants.mjs";
import { isObject, isFunction, isArray } from "../../../types/is.mjs";
import { diff } from "../../diff.mjs";
import { Server } from "../server.mjs";
import { WriteError } from "./restapi/writeerror.mjs";
import { DataFetchError } from "./restapi/data-fetch-error.mjs";
import { clone } from "../../../util/clone.mjs";
import { getInternalLocalizationMessage } from "../../../i18n/internal.mjs";

export { RestAPI };

/**
 * @type {symbol}
 * @license AGPLv3
 * @since 3.12.0
 */
const rawDataSymbol = Symbol.for(
	"@schukai/monster/data/datasource/server/restapi/rawdata",
);

/**
 * The RestAPI is a class that enables a REST API server.
 *
 * @fragments /fragments/libraries/data/datasource/server/restapi/
 *
 * @externalExample ../../../../example/data/datasource/server/restapi.mjs
 * @example /examples/libraries/data/datasource/server/restapi/simple/ Configure read and write endpoints
 * @example /examples/libraries/data/datasource/server/restapi/response-callback/ Replace the default read callback with a custom assignment flow
 * @example /examples/libraries/data/datasource/server/restapi/partial-write/ Patch only changed fields before sending a write request
 * @example /examples/libraries/data/datasource/server/restapi/report-path/ Read validation reports from a nested response path
 * @license AGPLv3
 * @since 1.22.0
 * @copyright Volker Schukai
 * @summary A REST-backed datasource for reading and writing application state over HTTP.
 */
class RestAPI extends Server {
	/**
	 *
	 * @param {Object} [options] options contains definitions for the datasource.
	 */
	constructor(options) {
		super();

		if (isObject(options)) {
			this.setOptions(options);
		}
	}

	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 * @since 2.1.0
	 */
	static get [instanceSymbol]() {
		return Symbol.for("@schukai/monster/data/datasource/server/restapi");
	}

	/**
	 * @property {Object} write={} Options
	 * @property {Object} write.init={} An option object, containing any custom settings that you want to apply to the request. The parameters are identical to those of the {@link https://developer.mozilla.org/en-US/docs/Web/API/Request/Request|Request constructor}
	 * @property {string} write.init.method=POST
	 * @property {Headers} write.init.headers Object containing any custom headers that you want to apply to the request.
	 * @property {string} write.responseCallback Callback function to be executed after the request has been completed.
	 * @property {string} write.acceptedStatus=[200,201]
	 * @property {string} write.url URL
	 * @property {Object} write.mapping the mapping is applied before writing.
	 * @property {String} write.mapping.transformer Transformer to select the appropriate entries
	 * @property {exampleCallback[]} write.mapping.callback with the help of the callback, the structures can be adjusted before writing.
	 * @property {Object} write.report
	 * @property {String} write.report.path Path to validations
	 * @property {Object} write.partial
	 * @property {Function} write.partial.callback Callback function to be executed after the request has been completed. (obj, diffResult) => obj
	 * @property {Object} write.sheathing
	 * @property {Object} write.sheathing.object Object to be wrapped
	 * @property {string} write.sheathing.path Path to the data
	 * @property {Object} read={} Options
	 * @property {Object} read.init={} An option object containing any custom settings that you want to apply to the request. The parameters are identical to those of the {@link https://developer.mozilla.org/en-US/docs/Web/API/Request/Request|Request constructor}
	 * @property {string} read.init.method=GET
	 * @property {array} read.acceptedStatus=[200]
	 * @property {string} read.url URL
	 * @property {Object} read.mapping the mapping is applied after reading.
	 * @property {String} read.mapping.transformer Transformer to select the appropriate entries
	 * @property {exampleCallback[]} read.mapping.callback with the help of the callback, the structures can be adjusted after reading.
	 */
	get defaults() {
		return Object.assign({}, super.defaults, {
			write: {
				init: {
					method: "POST",
					headers: {
						Accept: "application/json",
						"Content-Type": "application/json",
					},
				},
				responseCallback: null,
				acceptedStatus: [200, 201],
				url: null,
				mapping: {
					transformer: null,
					callbacks: [],
				},
				sheathing: {
					object: null,
					path: null,
				},
				report: {
					path: null,
				},

				partial: {
					callback: null,
				},
			},
			read: {
				init: {
					method: "GET",
					headers: {
						Accept: "application/json",
					},
				},
				path: null,
				responseCallback: null,
				acceptedStatus: [200],
				url: null,
				mapping: {
					transformer: null,
					callbacks: [],
				},
			},
		});
	}

	/**
	 * @return {Promise}
	 * @throws {Error} the options does not contain a valid json definition
	 * @throws {TypeError} value is not a object
	 * @throws {Error} the data cannot be read
	 */
	read() {
		let init = this.getOption("read.init");
		if (!isObject(init)) init = {};
		if (!(init["headers"] instanceof Headers) && !isObject(init["headers"])) {
			init["headers"] = new Headers();
			init["headers"].append("Accept", "application/json");
		}

		if (!init["method"]) init["method"] = "GET";

		let callback = this.getOption("read.responseCallback");
		if (!callback) {
			callback = (obj) => {
				const transformedPayload = this.transformServerPayload.call(this, obj);
				if (typeof transformedPayload === "undefined") {
					return;
				}
				this.set(transformedPayload);
			};
		}

		return fetchData.call(this, init, "read", callback);
	}

	/**
	 * @return {Promise}
	 * @throws {WriteError} the data cannot be written
	 */
	write() {
		let init = this.getOption("write.init");
		if (!isObject(init)) init = {};
		if (!(init["headers"] instanceof Headers) && !isObject(init["headers"])) {
			init["headers"] = new Headers();
			init["headers"].append("Accept", "application/json");
			init["headers"].append("Content-Type", "application/json");
		}
		if (!init["method"]) init["method"] = "POST";

		const obj = this.prepareServerPayload(this.get());
		init["body"] = JSON.stringify(obj);

		const callback = this.getOption("write.responseCallback");
		return fetchData.call(this, init, "write", callback);
	}

	/**
	 * @return {RestAPI}
	 */
	getClone() {
		const api = new RestAPI();

		const read = clone(this[internalSymbol].getRealSubject()["options"].read);
		const write = clone(this[internalSymbol].getRealSubject()["options"].write);

		api.setOption("read", read);
		api.setOption("write", write);

		return api;
	}
}

/**
 * @private
 * @param init
 * @param key
 * @param callback
 * @return {Promise<string>}
 */
function fetchData(init, key, callback) {
	let response;

	if (init?.headers === null) {
		init.headers = new Headers();
	}

	return fetch(this.getOption(`${key}.url`), init)
		.then((resp) => {
			response = resp;

			const acceptedStatus = this.getOption(`${key}.acceptedStatus`, [200]).map(
				Number,
			);

			if (acceptedStatus.indexOf(resp.status) === -1) {
				throw new DataFetchError(
					getInternalLocalizationMessage(
						`i18n{the-response-does-not-contain-an-accepted-status::status=${resp.status}}`,
					),
					response,
				);
			}

			return resp.text();
		})
		.then((body) => {
			let obj;

			try {
				obj = JSON.parse(body);

				response[rawDataSymbol] = obj;
			} catch (e) {
				if (body.length > 100) {
					body = `${body.substring(0, 97)}...`;
				}

				throw new DataFetchError(
					getInternalLocalizationMessage(
						`i18n{the-response-does-not-contain-a-valid-json::actual=${body}}`,
					),
					response,
				);
			}

			if (callback && isFunction(callback)) {
				callback(obj);
			}

			return response;
		})
		.catch((e) => {
			throw e;
		});
}
