/**
 * Copyright © schukai GmbH 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 schukai GmbH.
 *
 * SPDX-License-Identifier: AGPL-3.0
 */

import { getLocaleOfDocument } from "../dom/locale.mjs";
import { Base } from "../types/base.mjs";
import { getGlobal, getGlobalObject } from "../types/global.mjs";
import { ID } from "../types/id.mjs";
import { isArray, isObject, isString, isPrimitive } from "../types/is.mjs";
import {
	getDocumentTranslations,
	Translations,
} from "../i18n/translations.mjs";
import {
	validateFunction,
	validateInteger,
	validateObject,
	validatePrimitive,
	validateString,
	validateBoolean,
} from "../types/validate.mjs";
import { clone } from "../util/clone.mjs";
import { Pathfinder } from "./pathfinder.mjs";
import { formatTimeAgo } from "../i18n/time-ago.mjs";

export { Transformer };

/**
 * The transformer class is a swiss army knife for manipulating values.
 *
 * A simple example is the conversion of all characters to lowercase. for this purpose the command `tolower` must be used.
 *
 * ```js
 * let t = new Transformer('tolower').run('ABC'); // ↦ abc
 * ```
 *
 * @fragments /fragments/libraries/transformer
 *
 * @example /examples/libraries/transformer/simple
 *
 * @since 1.5.0
 * @copyright schukai GmbH
 * @summary The transformer class is a swiss army knife for manipulating values. especially in combination with the pipe, processing chains can be built up.
 */
class Transformer extends Base {
	/**
	 *
	 * @param {string} definition
	 */
	constructor(definition) {
		super();
		this.args = disassemble(definition);
		this.command = this.args.shift();
		this.callbacks = new Map();
	}

	/**
	 *
	 * @param {string} name
	 * @param {function} callback
	 * @param {object} context
	 * @return {Transformer}
	 * @throws {TypeError} value is not a string
	 * @throws {TypeError} value is not a function
	 */
	setCallback(name, callback, context) {
		validateString(name);
		validateFunction(callback);

		if (context !== undefined) {
			validateObject(context);
		}

		this.callbacks.set(name, {
			callback: callback,
			context: context,
		});

		return this;
	}

	/**
	 *
	 * @param {*} value
	 * @return {*}
	 * @throws {Error} unknown command
	 * @throws {TypeError} unsupported type
	 * @throws {Error} type not supported
	 */
	run(value) {
		return transform.apply(this, [value]);
	}
}

/**
 *
 * @param {string} command
 * @return {array}
 * @private
 */
function disassemble(command) {
	validateString(command);

	const placeholder = new Map();
	const regex = /((?<pattern>\\(?<char>.)){1})/gim;

	// The separator for args must be escaped
	// undefined string which should not occur normally and is also not a regex
	const result = command.matchAll(regex);

	for (const m of result) {
		const g = m?.["groups"];
		if (!isObject(g)) {
			continue;
		}

		const p = g?.["pattern"];
		const c = g?.["char"];

		if (p && c) {
			const r = `__${new ID().toString()}__`;
			placeholder.set(r, c);
			command = command.replace(p, r);
		}
	}
	let parts = command.split(":");

	parts = parts.map(function (value) {
		let v = value.trim();
		for (const k of placeholder) {
			v = v.replace(k[0], k[1]);
		}
		return v;
	});

	return parts;
}

/**
 * tries to make a string out of value and if this succeeds to return it back
 *
 * @param {*} value
 * @return {string}
 * @private
 */
function convertToString(value) {
	if (isObject(value) && value.hasOwnProperty("toString")) {
		value = value.toString();
	}

	validateString(value);
	return value;
}

/**
 *
 * @param {*} value
 * @return {*}
 * @private
 * @throws {Error} unknown command
 * @throws {TypeError} unsupported type
 * @throws {Error} type not supported
 * @throws {Error} missing key parameter
 */
function transform(value) {
	const console = getGlobalObject("console");

	const args = clone(this.args);
	let key;
	let defaultValue;

	let translations;
	let date;
	let locale;
	let timestamp;
	let map;
	let keyValue;

	switch (this.command) {
		case "static":
			return this.args.join(":");

		case "tolower":
		case "strtolower":
		case "tolowercase":
			validateString(value);
			return value.toLowerCase();

		case "contains":
			if (isString(value)) {
				return value.includes(args[0]);
			}

			if (isArray(value)) {
				return value.includes(args[0]);
			}

			if (isObject(value)) {
				return value.hasOwnProperty(args[0]);
			}

			return false;

		case "has-entries":
		case "hasentries":
			if (isObject(value)) {
				return Object.keys(value).length > 0;
			}

			if (isArray(value)) {
				return value.length > 0;
			}

			return false;

		case "isundefined":
		case "is-undefined":
			return value === undefined;

		case "isnull":
		case "is-null":
			return value === null;

		case "isset":
		case "is-set":
			return value !== undefined && value !== null;

		case "isnumber":
		case "is-number":
			return isPrimitive(value) && !isNaN(value);

		case "isinteger":
		case "is-integer":
			return isPrimitive(value) && !isNaN(value) && value % 1 === 0;

		case "isfloat":
		case "is-float":
			return isPrimitive(value) && !isNaN(value) && value % 1 !== 0;

		case "isobject":
		case "is-object":
			return isObject(value);

		case "isarray":
		case "is-array":
			return Array.isArray(value);

		case "not":
			validateBoolean(value);
			return !value;

		case "toupper":
		case "strtoupper":
		case "touppercase":
			validateString(value);
			return value.toUpperCase();

		case "to-string":
		case "tostring":
			return `${value}`;

		case "to-integer":
		case "tointeger":
			const n = parseInt(value);
			validateInteger(n);
			return n;

		case "to-json":
		case "tojson":
			return JSON.stringify(value);

		case "from-json":
		case "fromjson":
			return JSON.parse(value);

		case "trim":
			validateString(value);
			return value.trim();

		case "rawurlencode":
			validateString(value);
			return encodeURIComponent(value)
				.replace(/!/g, "%21")
				.replace(/'/g, "%27")
				.replace(/\(/g, "%28")
				.replace(/\)/g, "%29")
				.replace(/\*/g, "%2A");

		case "call":
			/**
			 * callback-definition
			 * function callback(value, ...args) {
			 *   return value;
			 * }
			 */

			let callback;
			const callbackName = args.shift();
			let context = getGlobal();

			if (isObject(value) && value.hasOwnProperty(callbackName)) {
				callback = value[callbackName];
			} else if (this.callbacks.has(callbackName)) {
				const s = this.callbacks.get(callbackName);
				callback = s?.["callback"];
				context = s?.["context"];
			} else if (
				typeof window === "object" &&
				window.hasOwnProperty(callbackName)
			) {
				callback = window[callbackName];
			}
			validateFunction(callback);

			args.unshift(value);
			return callback.call(context, ...args);

		case "plain":
		case "plaintext":
			validateString(value);
			const doc = new DOMParser().parseFromString(value, "text/html");
			return doc.body.textContent || "";

		case "if":
		case "?":
			validatePrimitive(value);

			let trueStatement = args.shift() || undefined;
			let falseStatement = args.shift() || undefined;

			trueStatement = convertSpecialStrings(trueStatement, value);
			falseStatement = convertSpecialStrings(falseStatement, value);

			const condition = evaluateCondition(value);
			return condition ? trueStatement : falseStatement;

		case "ucfirst":
			validateString(value);

			const firstchar = value.charAt(0).toUpperCase();
			return firstchar + value.substr(1);
		case "ucwords":
			validateString(value);

			return value.replace(
				/^([a-z\u00E0-\u00FC])|\s+([a-z\u00E0-\u00FC])/g,
				function (v) {
					return v.toUpperCase();
				},
			);

		case "count":
		case "length":
			if (
				(isString(value) || isObject(value) || isArray(value)) &&
				value.hasOwnProperty("length")
			) {
				return value.length;
			}

			throw new TypeError(`unsupported type ${typeof value}`);

		case "to-base64":
		case "btoa":
		case "base64":
			return btoa(convertToString(value));

		case "atob":
		case "from-base64":
			return atob(convertToString(value));

		case "empty":
			return "";

		case "undefined":
			return undefined;

		case "debug":
			if (isObject(console)) {
				console.log(value);
			}

			return value;

		case "prefix":
			validateString(value);
			const prefix = args?.[0];
			return prefix + value;

		case "suffix":
			validateString(value);
			const suffix = args?.[0];
			return value + suffix;

		case "uniqid":
			return new ID().toString();

		case "first-key":
		case "last-key":
		case "nth-last-key":
		case "nth-key":
			if (!isObject(value)) {
				throw new Error("type not supported");
			}

			const keys = Object.keys(value).sort();

			if (this.command === "first-key") {
				key = 0;
			} else if (this.command === "last-key") {
				key = keys.length - 1;
			} else {
				key = validateInteger(parseInt(args.shift()));

				if (this.command === "nth-last-key") {
					key = keys.length - key - 1;
				}
			}

			defaultValue = args.shift() || "";

			const useKey = keys?.[key];

			if (value?.[useKey]) {
				return value?.[useKey];
			}

			return defaultValue;

		case "key":
		case "property":
		case "index":
			key = args.shift() || undefined;

			if (key === undefined) {
				throw new Error("missing key parameter");
			}

			defaultValue = args.shift() || undefined;

			if (value instanceof Map) {
				if (!value.has(key)) {
					return defaultValue;
				}
				return value.get(key);
			}

			if (isObject(value) || isArray(value)) {
				if (value?.[key]) {
					return value?.[key];
				}

				return defaultValue;
			}

			throw new Error("type not supported");

		case "path-exists":
			key = args.shift();
			if (key === undefined) {
				throw new Error("missing key parameter");
			}

			return new Pathfinder(value).exists(key);

		case "concat":
			const pf2 = new Pathfinder(value);
			let concat = "";
			while (args.length > 0) {
				key = args.shift();
				if (key === undefined) {
					throw new Error("missing key parameter");
				}

				// add empty strings
				if (isString(key) && key.trim() === "") {
					concat += key;
					continue;
				}

				if (!pf2.exists(key)) {
					concat += key;
					continue;
				}
				const v = pf2.getVia(key);
				if (!isPrimitive(v)) {
					throw new Error("value is not primitive");
				}

				concat += v;
			}

			return concat;
		case "path":
			key = args.shift();
			if (key === undefined) {
				throw new Error("missing key parameter");
			}

			const pf = new Pathfinder(value);

			if (!pf.exists(key)) {
				return undefined;
			}

			return pf.getVia(key);

		case "substring":
			validateString(value);

			const start = parseInt(args[0]) || 0;
			const end = (parseInt(args[1]) || 0) + start;

			return value.substring(start, end);

		case "nop":
			return value;

		case "??":
		case "default":
			if (value !== undefined && value !== null) {
				return value;
			}

			defaultValue = args.shift();
			let defaultType = args.shift();
			if (defaultType === undefined) {
				defaultType = "string";
			}

			switch (defaultType) {
				case "int":
				case "integer":
					return parseInt(defaultValue);
				case "float":
					return parseFloat(defaultValue);
				case "undefined":
					return undefined;
				case "bool":
				case "boolean":
					defaultValue = defaultValue.toLowerCase();
					return (
						(defaultValue !== "undefined" &&
							defaultValue !== "" &&
							defaultValue !== "off" &&
							defaultValue !== "false" &&
							defaultValue !== "false") ||
						defaultValue === "on" ||
						defaultValue === "true" ||
						defaultValue === "true"
					);
				case "string":
					return `${defaultValue}`;
				case "object":
					return JSON.parse(atob(defaultValue));
			}

			throw new Error("type not supported");

		case "map":
			map = new Map();
			while (args.length > 0) {
				keyValue = args.shift();
				if (keyValue === undefined) {
					throw new Error("missing key parameter");
				}

				keyValue = keyValue.split("=");
				map.set(keyValue[0], keyValue[1]);
			}

			return map.get(value);

		case "equals":
			if (args.length === 0) {
				throw new Error("missing value parameter");
			}

			validatePrimitive(value);

			const equalsValue = args.shift();

			/**
			 * The history of “typeof null”
			 * https://2ality.com/2013/10/typeof-null.html
			 * In JavaScript, typeof null is 'object', which incorrectly suggests
			 * that null is an object.
			 */
			if (value === null) {
				return equalsValue === "null";
			}

			const typeOfValue = typeof value;

			switch (typeOfValue) {
				case "string":
					return value === equalsValue;
				case "number":
					return value === parseFloat(equalsValue);
				case "boolean":
					return value === (equalsValue === "true" || equalsValue === "on");
				case "undefined":
					return equalsValue === "undefined";
				default:
					throw new Error("type not supported");
			}

		case "money":
		case "currency":
			try {
				locale = getLocaleOfDocument();
			} catch (e) {
				throw new Error(`unsupported locale or missing format (${e.message})`);
			}

			// Verwenden von RegExp, um Währung und Betrag zu extrahieren
			const match = value.match(/^([A-Z]{3})[\s-]*(\d+(\.\d+)?)$/);
			if (!match) {
				throw new Error("invalid currency format");
			}

			const currency = match[1];
			const amount = match[2];

			const maximumFractionDigits = args?.[0] || 2;
			const roundingIncrement = args?.[1] || 5;

			const nf = new Intl.NumberFormat(locale.toString(), {
				style: "currency",
				currency: currency,
				maximumFractionDigits: maximumFractionDigits,
				roundingIncrement: roundingIncrement,
			});

			return nf.format(amount);

		case "timestamp":
			date = new Date(value);
			timestamp = date.getTime();
			if (isNaN(timestamp)) {
				throw new Error("invalid date");
			}
			return timestamp;

		case "time":
			date = new Date(value);
			if (isNaN(date.getTime())) {
				throw new Error("invalid date");
			}

			try {
				locale = getLocaleOfDocument();
				return date.toLocaleTimeString(locale.toString(), {
					hour12: false,
				});
			} catch (e) {
				throw new Error(`unsupported locale or missing format (${e.message})`);
			}

		case "datetimeformat":
			date = new Date(value);
			if (isNaN(date.getTime())) {
				throw new Error("invalid date");
			}

			const options = {
				dateStyle: "medium",
				timeStyle: "medium",
				hour12: false,
			};

			if (args.length > 0) {
				options.dateStyle = args.shift();
			}

			if (args.length > 0) {
				options.timeStyle = args.shift();
			}

			try {
				locale = getLocaleOfDocument().toString();
				return new Intl.DateTimeFormat(locale, options).format(date);
			} catch (e) {
				throw new Error(`unsupported locale or missing format (${e.message})`);
			}

		case "datetime":
			date = new Date(value);
			if (isNaN(date.getTime())) {
				throw new Error("invalid date");
			}

			try {
				locale = getLocaleOfDocument();
				return date.toLocaleString(locale.toString(), {
					hour12: false,
				});
			} catch (e) {
				throw new Error(`unsupported locale or missing format (${e.message})`);
			}

		case "date":
			date = new Date(value);
			if (isNaN(date.getTime())) {
				throw new Error("invalid date");
			}

			try {
				locale = getLocaleOfDocument();
				return date.toLocaleDateString(locale.toString(), {
					year: "numeric",
					month: "2-digit",
					day: "2-digit",
				});
			} catch (e) {
				throw new Error(`unsupported locale or missing format (${e.message})`);
			}

		case "time-ago":
			date = new Date(value);
			if (isNaN(date.getTime())) {
				throw new Error("invalid date");
			}

			try {
				locale = getLocaleOfDocument();
				return formatTimeAgo(date, locale.toString());
			} catch (e) {
				throw new Error(`unsupported locale or missing format (${e.message})`);
			}

		case "year":
			date = new Date(value);
			if (isNaN(date.getTime())) {
				throw new Error("invalid date");
			}

			return date.getFullYear();

		case "month":
			date = new Date(value);
			if (isNaN(date.getTime())) {
				throw new Error("invalid date");
			}

			return date.getMonth() + 1;

		case "day":
			date = new Date(value);
			if (isNaN(date.getTime())) {
				throw new Error("invalid date");
			}

			return date.getDate();

		case "weekday":
			date = new Date(value);
			if (isNaN(date.getTime())) {
				throw new Error("invalid date");
			}

			return date.getDay();

		case "hour":
		case "hours":
			date = new Date(value);
			if (isNaN(date.getTime())) {
				throw new Error("invalid date");
			}

			return date.getHours();

		case "minute":
		case "minutes":
			date = new Date(value);
			if (isNaN(date.getTime())) {
				throw new Error("invalid date");
			}

			return date.getMinutes();

		case "second":
		case "seconds":
			date = new Date(value);
			if (isNaN(date.getTime())) {
				throw new Error("invalid date");
			}

			return date.getSeconds();

		case "i18n":
		case "translation":
			translations = getDocumentTranslations();
			if (!(translations instanceof Translations)) {
				throw new Error("missing translations");
			}

			key = args.shift() || undefined;
			if (key === undefined) {
				key = value;
			}

			defaultValue = args.shift() || undefined;

			defaultValue = convertSpecialStrings(defaultValue, value);

			return translations.getText(key, defaultValue);

		case "set-toggle":
		case "set-set":
		case "set-remove":
			const modifier = args.shift();
			let delimiter = args.shift();
			if (delimiter === undefined) {
				delimiter = " ";
			}

			const set = new Set(value.split(delimiter));
			const toggle = new Set(modifier.split(delimiter));
			if (this.command === "set-toggle") {
				for (const t of toggle) {
					if (set.has(t)) {
						set.delete(t);
					} else {
						set.add(t);
					}
				}
			} else if (this.command === "set-set") {
				for (const t of toggle) {
					set.add(t);
				}
			} else if (this.command === "set-remove") {
				for (const t of toggle) {
					set.delete(t);
				}
			}
			return Array.from(set).join(delimiter);

		default:
			throw new Error(`unknown command ${this.command}`);
	}
}

/**
 * converts special strings to their values
 * @private
 * @param input
 * @param value
 * @return {undefined|*|null|string}
 */
function convertSpecialStrings(input, value) {
	switch (input) {
		case "value":
			return value;
		case "\\value":
			return "value";
		case "\\undefined":
			return undefined;
		case "\\null":
			return null;
		default:
			return input;
	}
}

/**
 * checks if a value is true or not
 * @param value
 * @return {boolean}
 */
function evaluateCondition(value) {
	const lowerValue = typeof value === "string" ? value.toLowerCase() : value;

	return (
		(value !== undefined &&
			value !== null &&
			value !== "" &&
			lowerValue !== "off" &&
			lowerValue !== "false" &&
			value !== false) ||
		lowerValue === "on" ||
		lowerValue === "true" ||
		value === true
	);
}