/**
 * 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 "escape-html":
        case "escapehtml":
            validateString(value);

            return value.replace(/&/g, "&amp;").
                replace(/</g, "&lt;").
                replace(/>/g, "&gt;").
                replace(/"/g, "&quot;").
                replace(/'/g, '&#39;');


        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 'ellipsize':
        case 'ellipsis':
        case 'ellipse':

            validateString(value);
            const length = parseInt(args[0]) || 0;
            const ellipsis = args[1] || '…';
            if (value.length <= length) {
                return value;
            }
            return value.substring(0, length) + ellipsis;


        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
    );
}