/**
 * Copyright schukai GmbH and contributors 2022. All Rights Reserved.
 * Node module: @schukai/monster
 * This file is licensed under the AGPLv3 License.
 * License text available at https://www.gnu.org/licenses/agpl-3.0.en.html
 */

import {Base} from "../types/base.mjs";
import {getGlobal, getGlobalObject} from "../types/global.mjs";
import {ID} from "../types/id.mjs";
import {isArray, isObject, isString} from "../types/is.mjs";
import {
    validateFunction,
    validateInteger,
    validateObject,
    validatePrimitive,
    validateString,
} from "../types/validate.mjs";
import {clone} from "../util/clone.mjs";
import {Pathfinder} from "./pathfinder.mjs";

export {Transformer};

/**
 * The transformer class is a swiss army knife for manipulating values. especially in combination with the pipe, processing chains can be built up.
 *
 * A simple example is the conversion of all characters to lowercase. for this purpose the command `tolower` must be used.
 *
 * ```
 * let t = new Transformer('tolower').run('ABC'); // ↦ abc
 * ```
 *
 * @see {@link https://monsterjs.org/en/doc/#transformer|Monster Docs}
 *
 * @externalExample ../../example/data/transformer.mjs
 * @license AGPLv3
 * @since 1.5.0
 * @copyright schukai GmbH
 * @memberOf Monster.Data
 */
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
     * @returns {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
     * @returns {*}
     * @throws {Error} unknown command
     * @throws {TypeError} unsupported type
     * @throws {Error} type not supported
     */
    run(value) {
        return transform.apply(this, [value]);
    }
}

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

    let 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
    let result = command.matchAll(regex);

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

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

        if (p && c) {
            let 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 (let 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
 * @returns {string}
 * @private
 */
function convertToString(value) {
    if (isObject(value) && value.hasOwnProperty("toString")) {
        value = value.toString();
    }

    validateString(value);
    return value;
}

/**
 *
 * @param {*} value
 * @returns {*}
 * @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");

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

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

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

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

        case "tostring":
            return `${value}`;

        case "tointeger":
            let n = parseInt(value);
            validateInteger(n);
            return n;

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

        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;
            let callbackName = args.shift();
            let context = getGlobal();

            if (isObject(value) && value.hasOwnProperty(callbackName)) {
                callback = value[callbackName];
            } else if (this.callbacks.has(callbackName)) {
                let 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);
            let 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;

            if (trueStatement === "value") {
                trueStatement = value;
            }
            if (trueStatement === "\\value") {
                trueStatement = "value";
            }
            if (falseStatement === "value") {
                falseStatement = value;
            }
            if (falseStatement === "\\value") {
                falseStatement = "value";
            }

            let condition =
                (value !== undefined && value !== "" && value !== "off" && value !== "false" && value !== false) ||
                value === "on" ||
                value === "true" ||
                value === true;
            return condition ? trueStatement : falseStatement;

        case "ucfirst":
            validateString(value);

            let 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);
            let prefix = args?.[0];
            return prefix + value;

        case "suffix":
            validateString(value);
            let 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() || "";

            let 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 "path":
            key = args.shift();
            if (key === undefined) {
                throw new Error("missing key parameter");
            }

            let pf = new Pathfinder(value);

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

            return pf.getVia(key);

        case "substring":
            validateString(value);

            let start = parseInt(args[0]) || 0;
            let 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");

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