/**
 * 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 { Pathfinder } from "../data/pathfinder.mjs";
import { ProxyObserver } from "../types/proxyobserver.mjs";
import { Observer } from "../types/observer.mjs";
import { isArray, isFunction, isObject, isString } from "../types/is.mjs";
import { getDocument } from "./util.mjs";

export { ControlFlow };

/**
 * Lightweight control flow for event-driven control dependencies.
 * Config is defined in JS and supports callbacks per event.
 */
class ControlFlow {
	constructor({
		stateObserver,
		state = {},
		controls = {},
		datasources = {},
		rules = [],
	} = {}) {
		this[internalSymbol] = {
			stateObserver:
				stateObserver instanceof ProxyObserver
					? stateObserver
					: new ProxyObserver(state),
			controls: resolveControls(controls),
			datasources: resolveDatasources(datasources),
			rules: isArray(rules) ? rules : [],
			listeners: [],
		};

		this.state = this[internalSymbol].stateObserver.getSubject();

		this[internalSymbol].stateObserver.attachObserver(
			new Observer(() => {
				this.emit({
					source: "state",
					id: "root",
					event: "changed",
					value: this.state,
				});
			}),
		);

		bindControlEvents.call(this);
		bindDatasourceEvents.call(this);
	}

	/**
	 * Create an API helper for rule handlers.
	 * @return {object}
	 */
	createApi() {
		return createApi(this);
	}

	/**
	 * Add a rule at runtime.
	 * @param {object} rule
	 */
	addRule(rule) {
		if (isObject(rule)) {
			this[internalSymbol].rules.push(rule);
		}
	}

	/**
	 * Remove all listeners.
	 */
	destroy() {
		for (const entry of this[internalSymbol].listeners) {
			entry.element.removeEventListener(entry.type, entry.handler);
		}
		this[internalSymbol].listeners = [];
	}

	/**
	 * Emit a synthetic event into the rule system.
	 * @param {object} ctx
	 */
	emit(ctx) {
		const context = normalizeContext(ctx, this);
		for (const rule of this[internalSymbol].rules) {
			if (!matchRule(rule, context)) {
				continue;
			}
			const run = rule?.run;
			if (isFunction(run)) {
				run(context, createApi(this));
			}
		}
	}

	/**
	 * Rule helper: control events.
	 * @param {string} id
	 * @param {string|string[]} events
	 * @param {function} run
	 * @return {object}
	 */
	static onControl(id, events, run) {
		return {
			on: (ctx) =>
				ctx.source === "control" &&
				ctx.id === id &&
				matchEvent(ctx.event, events),
			run,
		};
	}

	/**
	 * Rule helper: datasource events.
	 * @param {string} id
	 * @param {string|string[]} events
	 * @param {function} run
	 * @return {object}
	 */
	static onDatasource(id, events, run) {
		return {
			on: (ctx) =>
				ctx.source === "datasource" &&
				ctx.id === id &&
				matchEvent(ctx.event, events),
			run,
		};
	}

	/**
	 * Rule helper: state events.
	 * @param {string|string[]} events
	 * @param {function} run
	 * @return {object}
	 */
	static onState(events, run) {
		return {
			on: (ctx) => ctx.source === "state" && matchEvent(ctx.event, events),
			run,
		};
	}

	/**
	 * Rule helper: custom app events.
	 * @param {string|string[]} events
	 * @param {function} run
	 * @return {object}
	 */
	static onApp(events, run) {
		return {
			on: (ctx) => ctx.source === "app" && matchEvent(ctx.event, events),
			run,
		};
	}
}

/**
 * @private
 */
const internalSymbol = Symbol("controlFlowInternal");

/**
 * @private
 */
function resolveControls(controls) {
	const resolved = {};
	for (const [id, def] of Object.entries(controls || {})) {
		const element = resolveElement(def?.element || def);
		if (!element) continue;
		resolved[id] = {
			element,
			events: def?.events || ["monster-changed", "change"],
		};
	}
	return resolved;
}

/**
 * @private
 */
function resolveDatasources(datasources) {
	const resolved = {};
	for (const [id, def] of Object.entries(datasources || {})) {
		const element = resolveElement(def?.element || def);
		if (!element) continue;
		resolved[id] = {
			element,
			events: def?.events || [
				"monster-datasource-fetched",
				"monster-datasource-error",
			],
		};
	}
	return resolved;
}

/**
 * @private
 */
function resolveElement(elementOrSelector) {
	if (elementOrSelector instanceof HTMLElement) {
		return elementOrSelector;
	}
	if (isString(elementOrSelector)) {
		return getDocument().querySelector(elementOrSelector);
	}
	return null;
}

/**
 * @private
 */
function bindControlEvents() {
	for (const [id, control] of Object.entries(this[internalSymbol].controls)) {
		for (const type of control.events) {
			const handler = (event) => {
				this.emit({
					source: "control",
					id,
					event: type,
					value: readControlValue(control.element),
					rawEvent: event,
				});
			};
			control.element.addEventListener(type, handler);
			this[internalSymbol].listeners.push({
				element: control.element,
				type,
				handler,
			});
		}
	}
}

/**
 * @private
 */
function bindDatasourceEvents() {
	for (const [id, datasource] of Object.entries(
		this[internalSymbol].datasources,
	)) {
		for (const type of datasource.events) {
			const handler = (event) => {
				this.emit({
					source: "datasource",
					id,
					event: type,
					value: datasource.element?.data,
					rawEvent: event,
				});
			};
			datasource.element.addEventListener(type, handler);
			this[internalSymbol].listeners.push({
				element: datasource.element,
				type,
				handler,
			});
		}
	}
}

/**
 * @private
 */
function readControlValue(element) {
	if (element && "value" in element) {
		return element.value;
	}
	if (isFunction(element?.getOption)) {
		return element.getOption("value");
	}
	return undefined;
}

/**
 * @private
 */
function matchRule(rule, ctx) {
	if (!rule) return false;
	if (isFunction(rule.on)) {
		return !!rule.on(ctx);
	}
	if (isString(rule.on)) {
		return rule.on === ctx.key || rule.on === ctx.topic;
	}
	if (isArray(rule.on)) {
		return rule.on.includes(ctx.key) || rule.on.includes(ctx.topic);
	}
	return false;
}

/**
 * @private
 */
function normalizeContext(ctx, flow) {
	const context = Object.assign({}, ctx || {});
	context.key = `${context.source}:${context.id}:${context.event}`;
	context.topic = `${context.source}:${context.id}`;
	context.state = flow.state;
	context.controls = flow[internalSymbol].controls;
	context.datasources = flow[internalSymbol].datasources;
	return context;
}

/**
 * @private
 */
function matchEvent(event, events) {
	if (!events) return true;
	if (isString(events)) {
		return event === events;
	}
	if (isArray(events)) {
		return events.includes(event);
	}
	return false;
}

/**
 * @private
 */
function createApi(flow) {
	return {
		state: flow.state,
		getState(path, fallback) {
			try {
				return new Pathfinder(flow.state).getVia(path);
			} catch (e) {
				return fallback;
			}
		},
		setState(path, value) {
			new Pathfinder(flow.state).setVia(path, value);
		},
		getControl(id) {
			return flow[internalSymbol].controls?.[id]?.element;
		},
		setControlOption(id, path, value) {
			const control = flow[internalSymbol].controls?.[id]?.element;
			if (isFunction(control?.setOption)) {
				control.setOption(path, value);
			}
		},
		setControlOptions(id, options) {
			const control = flow[internalSymbol].controls?.[id]?.element;
			if (isFunction(control?.setOptions)) {
				control.setOptions(options);
			} else if (isFunction(control?.setOption)) {
				control.setOption("options", options);
			}
		},
		setControlValue(id, value) {
			const control = flow[internalSymbol].controls?.[id]?.element;
			if (control && "value" in control) {
				control.value = value;
			} else if (isFunction(control?.setOption)) {
				control.setOption("value", value);
			}
		},
		clearControl(id) {
			const control = flow[internalSymbol].controls?.[id]?.element;
			if (control && "value" in control) {
				control.value = "";
			} else if (isFunction(control?.setOption)) {
				control.setOption("value", "");
			}
		},
		disableControl(id, disabled) {
			const control = flow[internalSymbol].controls?.[id]?.element;
			if (isFunction(control?.setOption)) {
				control.setOption("disabled", !!disabled);
			}
		},
		fetchDatasource(id, { url, transform } = {}) {
			const datasource = flow[internalSymbol].datasources?.[id]?.element;
			if (!datasource) {
				return Promise.reject(new Error("datasource not found"));
			}
			return new Promise((resolve, reject) => {
				const onFetched = () => resolve(datasource.data);
				const onError = (event) => {
					reject(event?.detail?.error || new Error("Datasource error"));
				};
				datasource.addEventListener("monster-datasource-fetched", onFetched, {
					once: true,
				});
				datasource.addEventListener("monster-datasource-error", onError, {
					once: true,
				});
				if (isFunction(transform)) {
					datasource.setOption("read.responseCallback", (payload) => {
						datasource.data = transform(payload);
					});
				}
				if (url) {
					datasource.setOption("read.url", url);
				}
				datasource.read();
			});
		},
	};
}
