/**
 * 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 { internalSymbol } from "../constants.mjs";
import { extend } from "../data/extend.mjs";
import { Pipe } from "../data/pipe.mjs";

import { BaseWithOptions } from "../types/basewithoptions.mjs";
import { isObject, isString } from "../types/is.mjs";
import { validateArray, validateString } from "../types/validate.mjs";

export { Formatter };

/**
 * @private
 * @type {symbol}
 */
const internalObjectSymbol = Symbol("internalObject");

/**
 * @private
 * @type {symbol}
 */
const watchdogSymbol = Symbol("watchdog");

/**
 * @private
 * @type {symbol}
 */
const markerOpenIndexSymbol = Symbol("markerOpenIndex");

/**
 * @private
 * @type {symbol}
 */
const markerCloseIndexSymbol = Symbol("markercloseIndex");

/**
 * @private
 * @type {symbol}
 */
const workingDataSymbol = Symbol("workingData");

/**
 * Messages can be formatted with the formatter. To do this, an object with the values must be passed to the formatter. The message can then contain placeholders.
 *
 * Look at the example below. The placeholders use the logic of Pipe.
 *
 * ## Marker in marker
 *
 * Markers can be nested. Here, the inner marker is resolved first `${subkey} ↦ 1 = ${mykey2}` and then the outer marker `${mykey2}`.
 *
 * ```
 * const text = '${mykey${subkey}}';
 * let obj = {
 *  mykey2: "1",
 *  subkey: "2"
 * };
 *
 * new Formatter(obj).format(text);
 * // ↦ 1
 * ```
 *
 * ## Callbacks
 *
 * The values in a formatter can be adjusted via the commands of the `Transformer` or the`Pipe`.
 * There is also the possibility to use callbacks.
 *
 * const formatter = new Formatter({x: '1'}, {
 *                callbacks: {
 *                    quote: (value) => {
 *                        return '"' + value + '"'
 *                    }
 *                }
 *            });
 *
 * formatter.format('${x | call:quote}'))
 * // ↦ "1"
 *
 * ## Marker with parameter
 *
 * A string can also bring its own values. These must then be separated from the key by a separator `::`.
 * The values themselves must be specified in key/value pairs. The key must be separated from the value by a separator `=`.
 *
 * When using a pipe, you must pay attention to the separators.
 *
 * @example
 *
 * import {Formatter} from '@schukai/monster/source/text/formatter.mjs';
 *
 * new Formatter({
 *       a: {
 *           b: {
 *               c: "Hello"
 *           },
 *           d: "world",
 *       }
 *   }).format("${a.b.c} ${a.d | ucfirst}!"); // with pipe
 *
 * // ↦ Hello World!
 *
 * @license AGPLv3
 * @since 1.12.0
 * @copyright schukai GmbH
 */
class Formatter extends BaseWithOptions {
	/**
	 * Default values for the markers are `${` and `}`
	 *
	 * @param object
	 * @param options
	 */
	constructor(object, options) {
		super(options);
		this[internalObjectSymbol] = object || {};
		this[markerOpenIndexSymbol] = 0;
		this[markerCloseIndexSymbol] = 0;
	}

	/**
	 * @property {object} marker
	 * @property {array} marker.open=["${"]
	 * @property {array} marker.close=["${"]
	 * @property {object} parameter
	 * @property {string} parameter.delimiter="::"
	 * @property {string} parameter.assignment="="
	 * @property {object} callbacks={}
	 */
	get defaults() {
		return extend({}, super.defaults, {
			marker: {
				open: ["${"],
				close: ["}"],
			},
			parameter: {
				delimiter: "::",
				assignment: "=",
			},
			callbacks: {},
		});
	}

	/**
	 * Set new Parameter Character
	 *
	 * Default values for the chars are `::` and `=`
	 *
	 * ```
	 * formatter.setParameterChars('#');
	 * formatter.setParameterChars('[',']');
	 * formatter.setParameterChars('i18n{','}');
	 * ```
	 *
	 * @param {string} delimiter
	 * @param {string} assignment
	 * @return {Formatter}
	 * @since 1.24.0
	 * @throws {TypeError} value is not a string
	 */
	setParameterChars(delimiter, assignment) {
		if (delimiter !== undefined) {
			this[internalSymbol]["parameter"]["delimiter"] =
				validateString(delimiter);
		}

		if (assignment !== undefined) {
			this[internalSymbol]["parameter"]["assignment"] =
				validateString(assignment);
		}

		return this;
	}

	/**
	 * Set new Marker
	 *
	 * Default values for the markers are `${` and `}`
	 *
	 * ```
	 * formatter.setMarker('#'); // open and close are both #
	 * formatter.setMarker('[',']');
	 * formatter.setMarker('i18n{','}');
	 * ```
	 *
	 * @param {array|string} open
	 * @param {array|string|undefined} close
	 * @return {Formatter}
	 * @since 1.12.0
	 * @throws {TypeError} value is not a string
	 */
	setMarker(open, close) {
		if (close === undefined) {
			close = open;
		}

		if (isString(open)) open = [open];
		if (isString(close)) close = [close];

		this[internalSymbol]["marker"]["open"] = validateArray(open);
		this[internalSymbol]["marker"]["close"] = validateArray(close);
		return this;
	}

	/**
	 *
	 * @param {string} text
	 * @return {string}
	 * @throws {TypeError} value is not a string
	 * @throws {Error} too deep nesting
	 */
	format(text) {
		this[watchdogSymbol] = 0;
		this[markerOpenIndexSymbol] = 0;
		this[markerCloseIndexSymbol] = 0;
		this[workingDataSymbol] = {};
		return format.call(this, text);
	}
}

/**
 * @private
 * @return {string}
 */
function format(text) {
	this[watchdogSymbol]++;
	if (this[watchdogSymbol] > 20) {
		throw new Error("too deep nesting");
	}

	const openMarker =
		this[internalSymbol]["marker"]["open"]?.[this[markerOpenIndexSymbol]];
	const closeMarker =
		this[internalSymbol]["marker"]["close"]?.[this[markerCloseIndexSymbol]];

	// contains no placeholders
	if (text.indexOf(openMarker) === -1 || text.indexOf(closeMarker) === -1) {
		return text;
	}

	let result = tokenize.call(
		this,
		validateString(text),
		openMarker,
		closeMarker,
	);

	if (
		this[internalSymbol]["marker"]["open"]?.[this[markerOpenIndexSymbol] + 1]
	) {
		this[markerOpenIndexSymbol]++;
	}

	if (
		this[internalSymbol]["marker"]["close"]?.[this[markerCloseIndexSymbol] + 1]
	) {
		this[markerCloseIndexSymbol]++;
	}

	result = format.call(this, result);

	return result;
}

/**
 * @private
 * @license AGPLv3
 * @since 1.12.0
 *
 * @param {string} text
 * @param {string} openMarker
 * @param {string} closeMarker
 * @return {string}
 */
function tokenize(text, openMarker, closeMarker) {
	const formatted = [];

	const parameterAssignment = this[internalSymbol]["parameter"]["assignment"];
	const parameterDelimiter = this[internalSymbol]["parameter"]["delimiter"];
	const callbacks = this[internalSymbol]["callbacks"];

	while (true) {

		const startIndex = text.indexOf(openMarker);
		// no marker
		if (startIndex === -1) {
			formatted.push(text);
			break;
		} else if (startIndex > 0) {
			formatted.push(text.substring(0, startIndex));
			text = text.substring(startIndex);
		}

		let endIndex = text.substring(openMarker.length).indexOf(closeMarker);
		if (endIndex !== -1) endIndex += openMarker.length;
		let insideStartIndex = text
			.substring(openMarker.length)
			.indexOf(openMarker);
		if (insideStartIndex !== -1) {
			insideStartIndex += openMarker.length;
			if (insideStartIndex < endIndex) {
				const result = tokenize.call(
					this,
					text.substring(insideStartIndex),
					openMarker,
					closeMarker,
				);
				text = text.substring(0, insideStartIndex) + result;
				endIndex = text.substring(openMarker.length).indexOf(closeMarker);
				if (endIndex !== -1) endIndex += openMarker.length;
			}
		}

		if (endIndex === -1) {
			throw new Error("syntax error in formatter template");
		}

		const key = text.substring(openMarker.length, endIndex);
		const parts = key.split(parameterDelimiter);
		const currentPipe = parts.shift();

		this[workingDataSymbol] = extend(
			{},
			this[internalObjectSymbol],
			this[workingDataSymbol],
		);

		for (const kv of parts) {
			const [k, v] = kv.split(parameterAssignment);
			this[workingDataSymbol][k] = v;
		}

		const t1 = key.split("|").shift().trim(); // pipe symbol
		const t2 = t1.split("::").shift().trim(); // key value delimiter
		const t3 = t2.split(".").shift().trim(); // path delimiter
		const prefix = (t3 in this[workingDataSymbol]) ? "path:" : "static:";

		let command = "";
		if (
			prefix &&
			key.indexOf(prefix) !== 0 &&
			key.indexOf("path:") !== 0 &&
			key.indexOf("static:") !== 0
		) {
			command = prefix;
		}

		command += currentPipe;

		const pipe = new Pipe(command);

		if (isObject(callbacks)) {
			for (const [name, callback] of Object.entries(callbacks)) {
				pipe.setCallback(name, callback);
			}
		}

		formatted.push(validateString(pipe.run(this[workingDataSymbol])));
		text = text.substring(endIndex + closeMarker.length);
	}

	return formatted.join("");
}