/**
 * 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 } 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 recursionStackSymbol = Symbol("recursionStack");

/**
 * @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.
 *
 * @fragments /fragments/libraries/text/formatter/
 *
 * @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 Volker Schukai
 */
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: ["}"],
			},
			recursion: {
				maxDepth: 20,
				detectCycles: true,
			},
			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] = {};
		this[recursionStackSymbol] = [];
		return format.call(this, text);
	}
}

/**
 * @private
 * @return {string}
 */
function format(text) {
	this[watchdogSymbol]++;
	const recursionOptions = this[internalSymbol]["recursion"] || {};
	const maxDepth =
		typeof recursionOptions.maxDepth === "number"
			? recursionOptions.maxDepth
			: 20;
	const detectCycles = recursionOptions.detectCycles !== false;

	if (this[watchdogSymbol] > maxDepth) {
		throw new Error("too deep nesting");
	}

	validateString(text);

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

	const { text: tokenizedText, keys } = tokenize.call(
		this,
		validateString(text),
		openMarker,
		closeMarker,
		detectCycles,
	);

	let pushedKeys = 0;
	if (detectCycles && keys.length > 0) {
		for (const key of keys) {
			this[recursionStackSymbol].push(key);
		}
		pushedKeys = keys.length;
	}

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

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

	let result;
	try {
		result = format.call(this, tokenizedText);
	} finally {
		if (pushedKeys > 0) {
			this[recursionStackSymbol].splice(-pushedKeys);
		}
	}

	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, detectCycles) {
	const formatted = [];
	const keys = [];

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

	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,
					detectCycles,
				);
				text = text.substring(0, insideStartIndex) + result.text;
				if (detectCycles && result.keys?.length) {
					for (const key of result.keys) {
						if (!keys.includes(key)) {
							keys.push(key);
						}
					}
				}
				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();
		const recursionKey = getRecursionKey(currentPipe);

		if (detectCycles && recursionKey) {
			if (activeKeys.includes(recursionKey)) {
				throw new Error("cycle detected in formatter");
			}
			if (!keys.includes(recursionKey)) {
				keys.push(recursionKey);
			}
		}

		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 { text: formatted.join(""), keys };
}

/**
 * @private
 * @param {string} pipe
 * @return {string|null}
 */
function getRecursionKey(pipe) {
	if (!pipe) return null;
	const rawKey = pipe.split("|").shift().trim();
	if (rawKey === "") return null;
	if (rawKey.indexOf("path:") === 0) return rawKey.substring("path:".length);
	if (rawKey.indexOf("static:") === 0)
		return rawKey.substring("static:".length);
	return rawKey;
}
