import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { mergeIfValid } from "./widgetInputs.js";
import { ManageGroupDialog } from "./groupNodeManage.js";

const GROUP = Symbol();

const Workflow = {
	InUse: {
		Free: 0,
		Registered: 1,
		InWorkflow: 2,
	},
	isInUseGroupNode(name) {
		const id = `workflow/${name}`;
		// Check if lready registered/in use in this workflow
		if (app.graph.extra?.groupNodes?.[name]) {
			if (app.graph._nodes.find((n) => n.type === id)) {
				return Workflow.InUse.InWorkflow;
			} else {
				return Workflow.InUse.Registered;
			}
		}
		return Workflow.InUse.Free;
	},
	storeGroupNode(name, data) {
		let extra = app.graph.extra;
		if (!extra) app.graph.extra = extra = {};
		let groupNodes = extra.groupNodes;
		if (!groupNodes) extra.groupNodes = groupNodes = {};
		groupNodes[name] = data;
	},
};

class GroupNodeBuilder {
	constructor(nodes) {
		this.nodes = nodes;
	}

	build() {
		const name = this.getName();
		if (!name) return;

		// Sort the nodes so they are in execution order
		// this allows for widgets to be in the correct order when reconstructing
		this.sortNodes();

		this.nodeData = this.getNodeData();
		Workflow.storeGroupNode(name, this.nodeData);

		return { name, nodeData: this.nodeData };
	}

	getName() {
		const name = prompt("Enter group name");
		if (!name) return;
		const used = Workflow.isInUseGroupNode(name);
		switch (used) {
			case Workflow.InUse.InWorkflow:
				alert(
					"An in use group node with this name already exists embedded in this workflow, please remove any instances or use a new name."
				);
				return;
			case Workflow.InUse.Registered:
				if (!confirm("A group node with this name already exists embedded in this workflow, are you sure you want to overwrite it?")) {
					return;
				}
				break;
		}
		return name;
	}

	sortNodes() {
		// Gets the builders nodes in graph execution order
		const nodesInOrder = app.graph.computeExecutionOrder(false);
		this.nodes = this.nodes
			.map((node) => ({ index: nodesInOrder.indexOf(node), node }))
			.sort((a, b) => a.index - b.index || a.node.id - b.node.id)
			.map(({ node }) => node);
	}

	getNodeData() {
		const storeLinkTypes = (config) => {
			// Store link types for dynamically typed nodes e.g. reroutes
			for (const link of config.links) {
				const origin = app.graph.getNodeById(link[4]);
				const type = origin.outputs[link[1]].type;
				link.push(type);
			}
		};

		const storeExternalLinks = (config) => {
			// Store any external links to the group in the config so when rebuilding we add extra slots
			config.external = [];
			for (let i = 0; i < this.nodes.length; i++) {
				const node = this.nodes[i];
				if (!node.outputs?.length) continue;
				for (let slot = 0; slot < node.outputs.length; slot++) {
					let hasExternal = false;
					const output = node.outputs[slot];
					let type = output.type;
					if (!output.links?.length) continue;
					for (const l of output.links) {
						const link = app.graph.links[l];
						if (!link) continue;
						if (type === "*") type = link.type;

						if (!app.canvas.selected_nodes[link.target_id]) {
							hasExternal = true;
							break;
						}
					}
					if (hasExternal) {
						config.external.push([i, slot, type]);
					}
				}
			}
		};

		// Use the built in copyToClipboard function to generate the node data we need
		const backup = localStorage.getItem("litegrapheditor_clipboard");
		try {
			app.canvas.copyToClipboard(this.nodes);
			const config = JSON.parse(localStorage.getItem("litegrapheditor_clipboard"));

			storeLinkTypes(config);
			storeExternalLinks(config);

			return config;
		} finally {
			localStorage.setItem("litegrapheditor_clipboard", backup);
		}
	}
}

export class GroupNodeConfig {
	constructor(name, nodeData) {
		this.name = name;
		this.nodeData = nodeData;
		this.getLinks();

		this.inputCount = 0;
		this.oldToNewOutputMap = {};
		this.newToOldOutputMap = {};
		this.oldToNewInputMap = {};
		this.oldToNewWidgetMap = {};
		this.newToOldWidgetMap = {};
		this.primitiveDefs = {};
		this.widgetToPrimitive = {};
		this.primitiveToWidget = {};
		this.nodeInputs = {};
		this.outputVisibility = [];
	}

	async registerType(source = "workflow") {
		this.nodeDef = {
			output: [],
			output_name: [],
			output_is_list: [],
			output_is_hidden: [],
			name: source + "/" + this.name,
			display_name: this.name,
			category: "group nodes" + ("/" + source),
			input: { required: {} },

			[GROUP]: this,
		};

		this.inputs = [];
		const seenInputs = {};
		const seenOutputs = {};
		for (let i = 0; i < this.nodeData.nodes.length; i++) {
			const node = this.nodeData.nodes[i];
			node.index = i;
			this.processNode(node, seenInputs, seenOutputs);
		}

		for (const p of this.#convertedToProcess) {
			p();
		}
		this.#convertedToProcess = null;
		await app.registerNodeDef("workflow/" + this.name, this.nodeDef);
	}

	getLinks() {
		this.linksFrom = {};
		this.linksTo = {};
		this.externalFrom = {};

		// Extract links for easy lookup
		for (const l of this.nodeData.links) {
			const [sourceNodeId, sourceNodeSlot, targetNodeId, targetNodeSlot] = l;

			// Skip links outside the copy config
			if (sourceNodeId == null) continue;

			if (!this.linksFrom[sourceNodeId]) {
				this.linksFrom[sourceNodeId] = {};
			}
			if (!this.linksFrom[sourceNodeId][sourceNodeSlot]) {
				this.linksFrom[sourceNodeId][sourceNodeSlot] = [];
			}
			this.linksFrom[sourceNodeId][sourceNodeSlot].push(l);

			if (!this.linksTo[targetNodeId]) {
				this.linksTo[targetNodeId] = {};
			}
			this.linksTo[targetNodeId][targetNodeSlot] = l;
		}

		if (this.nodeData.external) {
			for (const ext of this.nodeData.external) {
				if (!this.externalFrom[ext[0]]) {
					this.externalFrom[ext[0]] = { [ext[1]]: ext[2] };
				} else {
					this.externalFrom[ext[0]][ext[1]] = ext[2];
				}
			}
		}
	}

	processNode(node, seenInputs, seenOutputs) {
		const def = this.getNodeDef(node);
		if (!def) return;

		const inputs = { ...def.input?.required, ...def.input?.optional };

		this.inputs.push(this.processNodeInputs(node, seenInputs, inputs));
		if (def.output?.length) this.processNodeOutputs(node, seenOutputs, def);
	}

	getNodeDef(node) {
		const def = globalDefs[node.type];
		if (def) return def;

		const linksFrom = this.linksFrom[node.index];
		if (node.type === "PrimitiveNode") {
			// Skip as its not linked
			if (!linksFrom) return;

			let type = linksFrom["0"][0][5];
			if (type === "COMBO") {
				// Use the array items
				const source = node.outputs[0].widget.name;
				const fromTypeName = this.nodeData.nodes[linksFrom["0"][0][2]].type;
				const fromType = globalDefs[fromTypeName];
				const input = fromType.input.required[source] ?? fromType.input.optional[source];
				type = input[0];
			}

			const def = (this.primitiveDefs[node.index] = {
				input: {
					required: {
						value: [type, {}],
					},
				},
				output: [type],
				output_name: [],
				output_is_list: [],
			});
			return def;
		} else if (node.type === "Reroute") {
			const linksTo = this.linksTo[node.index];
			if (linksTo && linksFrom && !this.externalFrom[node.index]?.[0]) {
				// Being used internally
				return null;
			}

			let config = {};
			let rerouteType = "*";
			if (linksFrom) {
				for (const [, , id, slot] of linksFrom["0"]) {
					const node = this.nodeData.nodes[id];
					const input = node.inputs[slot];
					if (rerouteType === "*") {
						rerouteType = input.type;
					}
					if (input.widget) {
						const targetDef = globalDefs[node.type];
						const targetWidget = targetDef.input.required[input.widget.name] ?? targetDef.input.optional[input.widget.name];

						const widget = [targetWidget[0], config];
						const res = mergeIfValid(
							{
								widget,
							},
							targetWidget,
							false,
							null,
							widget
						);
						config = res?.customConfig ?? config;
					}
				}
			} else if (linksTo) {
				const [id, slot] = linksTo["0"];
				rerouteType = this.nodeData.nodes[id].outputs[slot].type;
			} else {
				// Reroute used as a pipe
				for (const l of this.nodeData.links) {
					if (l[2] === node.index) {
						rerouteType = l[5];
						break;
					}
				}
				if (rerouteType === "*") {
					// Check for an external link
					const t = this.externalFrom[node.index]?.[0];
					if (t) {
						rerouteType = t;
					}
				}
			}

			config.forceInput = true;
			return {
				input: {
					required: {
						[rerouteType]: [rerouteType, config],
					},
				},
				output: [rerouteType],
				output_name: [],
				output_is_list: [],
			};
		}

		console.warn("Skipping virtual node " + node.type + " when building group node " + this.name);
	}

	getInputConfig(node, inputName, seenInputs, config, extra) {
		const customConfig = this.nodeData.config?.[node.index]?.input?.[inputName];
		let name = customConfig?.name ?? node.inputs?.find((inp) => inp.name === inputName)?.label ?? inputName;
		let key = name;
		let prefix = "";
		// Special handling for primitive to include the title if it is set rather than just "value"
		if ((node.type === "PrimitiveNode" && node.title) || name in seenInputs) {
			prefix = `${node.title ?? node.type} `;
			key = name = `${prefix}${inputName}`;
			if (name in seenInputs) {
				name = `${prefix}${seenInputs[name]} ${inputName}`;
			}
		}
		seenInputs[key] = (seenInputs[key] ?? 1) + 1;

		if (inputName === "seed" || inputName === "noise_seed") {
			if (!extra) extra = {};
			extra.control_after_generate = `${prefix}control_after_generate`;
		}
		if (config[0] === "IMAGEUPLOAD") {
			if (!extra) extra = {};
			extra.widget = this.oldToNewWidgetMap[node.index]?.[config[1]?.widget ?? "image"] ?? "image";
		}

		if (extra) {
			config = [config[0], { ...config[1], ...extra }];
		}

		return { name, config, customConfig };
	}

	processWidgetInputs(inputs, node, inputNames, seenInputs) {
		const slots = [];
		const converted = new Map();
		const widgetMap = (this.oldToNewWidgetMap[node.index] = {});
		for (const inputName of inputNames) {
			let widgetType = app.getWidgetType(inputs[inputName], inputName);
			if (widgetType) {
				const convertedIndex = node.inputs?.findIndex((inp) => inp.name === inputName && inp.widget?.name === inputName);
				if (convertedIndex > -1) {
					// This widget has been converted to a widget
					// We need to store this in the correct position so link ids line up
					converted.set(convertedIndex, inputName);
					widgetMap[inputName] = null;
				} else {
					// Normal widget
					const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName]);
					this.nodeDef.input.required[name] = config;
					widgetMap[inputName] = name;
					this.newToOldWidgetMap[name] = { node, inputName };
				}
			} else {
				// Normal input
				slots.push(inputName);
			}
		}
		return { converted, slots };
	}

	checkPrimitiveConnection(link, inputName, inputs) {
		const sourceNode = this.nodeData.nodes[link[0]];
		if (sourceNode.type === "PrimitiveNode") {
			// Merge link configurations
			const [sourceNodeId, _, targetNodeId, __] = link;
			const primitiveDef = this.primitiveDefs[sourceNodeId];
			const targetWidget = inputs[inputName];
			const primitiveConfig = primitiveDef.input.required.value;
			const output = { widget: primitiveConfig };
			const config = mergeIfValid(output, targetWidget, false, null, primitiveConfig);
			primitiveConfig[1] = config?.customConfig ?? inputs[inputName][1] ? { ...inputs[inputName][1] } : {};

			let name = this.oldToNewWidgetMap[sourceNodeId]["value"];
			name = name.substr(0, name.length - 6);
			primitiveConfig[1].control_after_generate = true;
			primitiveConfig[1].control_prefix = name;

			let toPrimitive = this.widgetToPrimitive[targetNodeId];
			if (!toPrimitive) {
				toPrimitive = this.widgetToPrimitive[targetNodeId] = {};
			}
			if (toPrimitive[inputName]) {
				toPrimitive[inputName].push(sourceNodeId);
			}
			toPrimitive[inputName] = sourceNodeId;

			let toWidget = this.primitiveToWidget[sourceNodeId];
			if (!toWidget) {
				toWidget = this.primitiveToWidget[sourceNodeId] = [];
			}
			toWidget.push({ nodeId: targetNodeId, inputName });
		}
	}

	processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs) {
		this.nodeInputs[node.index] = {};
		for (let i = 0; i < slots.length; i++) {
			const inputName = slots[i];
			if (linksTo[i]) {
				this.checkPrimitiveConnection(linksTo[i], inputName, inputs);
				// This input is linked so we can skip it
				continue;
			}

			const { name, config, customConfig } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName]);

			this.nodeInputs[node.index][inputName] = name;
			if(customConfig?.visible === false) continue;
			
			this.nodeDef.input.required[name] = config;
			inputMap[i] = this.inputCount++;
		}
	}

	processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs) {
		// Add converted widgets sorted into their index order (ordered as they were converted) so link ids match up
		const convertedSlots = [...converted.keys()].sort().map((k) => converted.get(k));
		for (let i = 0; i < convertedSlots.length; i++) {
			const inputName = convertedSlots[i];
			if (linksTo[slots.length + i]) {
				this.checkPrimitiveConnection(linksTo[slots.length + i], inputName, inputs);
				// This input is linked so we can skip it
				continue;
			}

			const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName], {
				defaultInput: true,
			});

			this.nodeDef.input.required[name] = config;
			this.newToOldWidgetMap[name] = { node, inputName };

			if (!this.oldToNewWidgetMap[node.index]) {
				this.oldToNewWidgetMap[node.index] = {};
			}
			this.oldToNewWidgetMap[node.index][inputName] = name;

			inputMap[slots.length + i] = this.inputCount++;
		}
	}

	#convertedToProcess = [];
	processNodeInputs(node, seenInputs, inputs) {
		const inputMapping = [];

		const inputNames = Object.keys(inputs);
		if (!inputNames.length) return;

		const { converted, slots } = this.processWidgetInputs(inputs, node, inputNames, seenInputs);
		const linksTo = this.linksTo[node.index] ?? {};
		const inputMap = (this.oldToNewInputMap[node.index] = {});
		this.processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs);

		// Converted inputs have to be processed after all other nodes as they'll be at the end of the list
		this.#convertedToProcess.push(() => this.processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs));

		return inputMapping;
	}

	processNodeOutputs(node, seenOutputs, def) {
		const oldToNew = (this.oldToNewOutputMap[node.index] = {});

		// Add outputs
		for (let outputId = 0; outputId < def.output.length; outputId++) {
			const linksFrom = this.linksFrom[node.index];
			// If this output is linked internally we flag it to hide
			const hasLink = linksFrom?.[outputId] && !this.externalFrom[node.index]?.[outputId];
			const customConfig = this.nodeData.config?.[node.index]?.output?.[outputId];
			const visible = customConfig?.visible ?? !hasLink;
			this.outputVisibility.push(visible);
			if (!visible) {
				continue;
			}

			oldToNew[outputId] = this.nodeDef.output.length;
			this.newToOldOutputMap[this.nodeDef.output.length] = { node, slot: outputId };
			this.nodeDef.output.push(def.output[outputId]);
			this.nodeDef.output_is_list.push(def.output_is_list[outputId]);

			let label = customConfig?.name;
			if (!label) {
				label = def.output_name?.[outputId] ?? def.output[outputId];
				const output = node.outputs.find((o) => o.name === label);
				if (output?.label) {
					label = output.label;
				}
			}

			let name = label;
			if (name in seenOutputs) {
				const prefix = `${node.title ?? node.type} `;
				name = `${prefix}${label}`;
				if (name in seenOutputs) {
					name = `${prefix}${node.index} ${label}`;
				}
			}
			seenOutputs[name] = 1;

			this.nodeDef.output_name.push(name);
		}
	}

	static async registerFromWorkflow(groupNodes, missingNodeTypes) {
		const clean = app.clean;
		app.clean = function () {
			for (const g in groupNodes) {
				try {
					LiteGraph.unregisterNodeType("workflow/" + g);
				} catch (error) {}
			}
			app.clean = clean;
		};

		for (const g in groupNodes) {
			const groupData = groupNodes[g];

			let hasMissing = false;
			for (const n of groupData.nodes) {
				// Find missing node types
				if (!(n.type in LiteGraph.registered_node_types)) {
					missingNodeTypes.push({
						type: n.type,
						hint: ` (In group node 'workflow/${g}')`,
					});

					missingNodeTypes.push({
						type: "workflow/" + g,
						action: {
							text: "Remove from workflow",
							callback: (e) => {
								delete groupNodes[g];
								e.target.textContent = "Removed";
								e.target.style.pointerEvents = "none";
								e.target.style.opacity = 0.7;
							},
						},
					});

					hasMissing = true;
				}
			}

			if (hasMissing) continue;

			const config = new GroupNodeConfig(g, groupData);
			await config.registerType();
		}
	}
}

export class GroupNodeHandler {
	node;
	groupData;

	constructor(node) {
		this.node = node;
		this.groupData = node.constructor?.nodeData?.[GROUP];

		this.node.setInnerNodes = (innerNodes) => {
			this.innerNodes = innerNodes;

			for (let innerNodeIndex = 0; innerNodeIndex < this.innerNodes.length; innerNodeIndex++) {
				const innerNode = this.innerNodes[innerNodeIndex];

				for (const w of innerNode.widgets ?? []) {
					if (w.type === "converted-widget") {
						w.serializeValue = w.origSerializeValue;
					}
				}

				innerNode.index = innerNodeIndex;
				innerNode.getInputNode = (slot) => {
					// Check if this input is internal or external
					const externalSlot = this.groupData.oldToNewInputMap[innerNode.index]?.[slot];
					if (externalSlot != null) {
						return this.node.getInputNode(externalSlot);
					}

					// Internal link
					const innerLink = this.groupData.linksTo[innerNode.index]?.[slot];
					if (!innerLink) return null;

					const inputNode = innerNodes[innerLink[0]];
					// Primitives will already apply their values
					if (inputNode.type === "PrimitiveNode") return null;

					return inputNode;
				};

				innerNode.getInputLink = (slot) => {
					const externalSlot = this.groupData.oldToNewInputMap[innerNode.index]?.[slot];
					if (externalSlot != null) {
						// The inner node is connected via the group node inputs
						const linkId = this.node.inputs[externalSlot].link;
						let link = app.graph.links[linkId];

						// Use the outer link, but update the target to the inner node
						link = {
							...link,
							target_id: innerNode.id,
							target_slot: +slot,
						};
						return link;
					}

					let link = this.groupData.linksTo[innerNode.index]?.[slot];
					if (!link) return null;
					// Use the inner link, but update the origin node to be inner node id
					link = {
						origin_id: innerNodes[link[0]].id,
						origin_slot: link[1],
						target_id: innerNode.id,
						target_slot: +slot,
					};
					return link;
				};
			}
		};

		this.node.updateLink = (link) => {
			// Replace the group node reference with the internal node
			link = { ...link };
			const output = this.groupData.newToOldOutputMap[link.origin_slot];
			let innerNode = this.innerNodes[output.node.index];
			let l;
			while (innerNode?.type === "Reroute") {
				l = innerNode.getInputLink(0);
				innerNode = innerNode.getInputNode(0);
			}

			if (!innerNode) {
				return null;
			}

			if (l && GroupNodeHandler.isGroupNode(innerNode)) {
				return innerNode.updateLink(l);
			}

			link.origin_id = innerNode.id;
			link.origin_slot = l?.origin_slot ?? output.slot;
			return link;
		};

		this.node.getInnerNodes = () => {
			if (!this.innerNodes) {
				this.node.setInnerNodes(
					this.groupData.nodeData.nodes.map((n, i) => {
						const innerNode = LiteGraph.createNode(n.type);
						innerNode.configure(n);
						innerNode.id = `${this.node.id}:${i}`;
						return innerNode;
					})
				);
			}

			this.updateInnerWidgets();

			return this.innerNodes;
		};

		this.node.recreate = async () => {
			const id = this.node.id;
			const sz = this.node.size;
			const nodes = this.node.convertToNodes();

			const groupNode = LiteGraph.createNode(this.node.type);
			groupNode.id = id;

			// Reuse the existing nodes for this instance
			groupNode.setInnerNodes(nodes);
			groupNode[GROUP].populateWidgets();
			app.graph.add(groupNode);
			groupNode.size = [Math.max(groupNode.size[0], sz[0]), Math.max(groupNode.size[1], sz[1])];

			// Remove all converted nodes and relink them
			groupNode[GROUP].replaceNodes(nodes);
			return groupNode;
		};

		this.node.convertToNodes = () => {
			const addInnerNodes = () => {
				const backup = localStorage.getItem("litegrapheditor_clipboard");
				// Clone the node data so we dont mutate it for other nodes
				const c = { ...this.groupData.nodeData };
				c.nodes = [...c.nodes];
				const innerNodes = this.node.getInnerNodes();
				let ids = [];
				for (let i = 0; i < c.nodes.length; i++) {
					let id = innerNodes?.[i]?.id;
					// Use existing IDs if they are set on the inner nodes
					if (id == null || isNaN(id)) {
						id = undefined;
					} else {
						ids.push(id);
					}
					c.nodes[i] = { ...c.nodes[i], id };
				}
				localStorage.setItem("litegrapheditor_clipboard", JSON.stringify(c));
				app.canvas.pasteFromClipboard();
				localStorage.setItem("litegrapheditor_clipboard", backup);

				const [x, y] = this.node.pos;
				let top;
				let left;
				// Configure nodes with current widget data
				const selectedIds = ids.length ? ids : Object.keys(app.canvas.selected_nodes);
				const newNodes = [];
				for (let i = 0; i < selectedIds.length; i++) {
					const id = selectedIds[i];
					const newNode = app.graph.getNodeById(id);
					const innerNode = innerNodes[i];
					newNodes.push(newNode);

					if (left == null || newNode.pos[0] < left) {
						left = newNode.pos[0];
					}
					if (top == null || newNode.pos[1] < top) {
						top = newNode.pos[1];
					}

					if (!newNode.widgets) continue;

					const map = this.groupData.oldToNewWidgetMap[innerNode.index];
					if (map) {
						const widgets = Object.keys(map);

						for (const oldName of widgets) {
							const newName = map[oldName];
							if (!newName) continue;

							const widgetIndex = this.node.widgets.findIndex((w) => w.name === newName);
							if (widgetIndex === -1) continue;

							// Populate the main and any linked widgets
							if (innerNode.type === "PrimitiveNode") {
								for (let i = 0; i < newNode.widgets.length; i++) {
									newNode.widgets[i].value = this.node.widgets[widgetIndex + i].value;
								}
							} else {
								const outerWidget = this.node.widgets[widgetIndex];
								const newWidget = newNode.widgets.find((w) => w.name === oldName);
								if (!newWidget) continue;

								newWidget.value = outerWidget.value;
								for (let w = 0; w < outerWidget.linkedWidgets?.length; w++) {
									newWidget.linkedWidgets[w].value = outerWidget.linkedWidgets[w].value;
								}
							}
						}
					}
				}

				// Shift each node
				for (const newNode of newNodes) {
					newNode.pos = [newNode.pos[0] - (left - x), newNode.pos[1] - (top - y)];
				}

				return { newNodes, selectedIds };
			};

			const reconnectInputs = (selectedIds) => {
				for (const innerNodeIndex in this.groupData.oldToNewInputMap) {
					const id = selectedIds[innerNodeIndex];
					const newNode = app.graph.getNodeById(id);
					const map = this.groupData.oldToNewInputMap[innerNodeIndex];
					for (const innerInputId in map) {
						const groupSlotId = map[innerInputId];
						if (groupSlotId == null) continue;
						const slot = node.inputs[groupSlotId];
						if (slot.link == null) continue;
						const link = app.graph.links[slot.link];
						if (!link) continue;
						//  connect this node output to the input of another node
						const originNode = app.graph.getNodeById(link.origin_id);
						originNode.connect(link.origin_slot, newNode, +innerInputId);
					}
				}
			};

			const reconnectOutputs = (selectedIds) => {
				for (let groupOutputId = 0; groupOutputId < node.outputs?.length; groupOutputId++) {
					const output = node.outputs[groupOutputId];
					if (!output.links) continue;
					const links = [...output.links];
					for (const l of links) {
						const slot = this.groupData.newToOldOutputMap[groupOutputId];
						const link = app.graph.links[l];
						const targetNode = app.graph.getNodeById(link.target_id);
						const newNode = app.graph.getNodeById(selectedIds[slot.node.index]);
						newNode.connect(slot.slot, targetNode, link.target_slot);
					}
				}
			};

			const { newNodes, selectedIds } = addInnerNodes();
			reconnectInputs(selectedIds);
			reconnectOutputs(selectedIds);
			app.graph.remove(this.node);

			return newNodes;
		};

		const getExtraMenuOptions = this.node.getExtraMenuOptions;
		this.node.getExtraMenuOptions = function (_, options) {
			getExtraMenuOptions?.apply(this, arguments);

			let optionIndex = options.findIndex((o) => o.content === "Outputs");
			if (optionIndex === -1) optionIndex = options.length;
			else optionIndex++;
			options.splice(
				optionIndex,
				0,
				null,
				{
					content: "Convert to nodes",
					callback: () => {
						return this.convertToNodes();
					},
				},
				{
					content: "Manage Group Node",
					callback: () => {
						new ManageGroupDialog(app).show(this.type);
					},
				}
			);
		};

		// Draw custom collapse icon to identity this as a group
		const onDrawTitleBox = this.node.onDrawTitleBox;
		this.node.onDrawTitleBox = function (ctx, height, size, scale) {
			onDrawTitleBox?.apply(this, arguments);

			const fill = ctx.fillStyle;
			ctx.beginPath();
			ctx.rect(11, -height + 11, 2, 2);
			ctx.rect(14, -height + 11, 2, 2);
			ctx.rect(17, -height + 11, 2, 2);
			ctx.rect(11, -height + 14, 2, 2);
			ctx.rect(14, -height + 14, 2, 2);
			ctx.rect(17, -height + 14, 2, 2);
			ctx.rect(11, -height + 17, 2, 2);
			ctx.rect(14, -height + 17, 2, 2);
			ctx.rect(17, -height + 17, 2, 2);

			ctx.fillStyle = this.boxcolor || LiteGraph.NODE_DEFAULT_BOXCOLOR;
			ctx.fill();
			ctx.fillStyle = fill;
		};

		// Draw progress label
		const onDrawForeground = node.onDrawForeground;
		const groupData = this.groupData.nodeData;
		node.onDrawForeground = function (ctx) {
			const r = onDrawForeground?.apply?.(this, arguments);
			if (+app.runningNodeId === this.id && this.runningInternalNodeId !== null) {
				const n = groupData.nodes[this.runningInternalNodeId];
				if(!n) return;
				const message = `Running ${n.title || n.type} (${this.runningInternalNodeId}/${groupData.nodes.length})`;
				ctx.save();
				ctx.font = "12px sans-serif";
				const sz = ctx.measureText(message);
				ctx.fillStyle = node.boxcolor || LiteGraph.NODE_DEFAULT_BOXCOLOR;
				ctx.beginPath();
				ctx.roundRect(0, -LiteGraph.NODE_TITLE_HEIGHT - 20, sz.width + 12, 20, 5);
				ctx.fill();

				ctx.fillStyle = "#fff";
				ctx.fillText(message, 6, -LiteGraph.NODE_TITLE_HEIGHT - 6);
				ctx.restore();
			}
		};

		// Flag this node as needing to be reset
		const onExecutionStart = this.node.onExecutionStart;
		this.node.onExecutionStart = function () {
			this.resetExecution = true;
			return onExecutionStart?.apply(this, arguments);
		};

		const self = this;
		const onNodeCreated = this.node.onNodeCreated;
		this.node.onNodeCreated = function () {
			if (!this.widgets) {
				return;
			}
			const config = self.groupData.nodeData.config;
			if (config) {
				for (const n in config) {
					const inputs = config[n]?.input;
					for (const w in inputs) {
						if (inputs[w].visible !== false) continue;
						const widgetName = self.groupData.oldToNewWidgetMap[n][w];
						const widget = this.widgets.find((w) => w.name === widgetName);
						if (widget) {
							widget.type = "hidden";
							widget.computeSize = () => [0, -4];
						}
					}
				}
			}

			return onNodeCreated?.apply(this, arguments);
		};

		function handleEvent(type, getId, getEvent) {
			const handler = ({ detail }) => {
				const id = getId(detail);
				if (!id) return;
				const node = app.graph.getNodeById(id);
				if (node) return;

				const innerNodeIndex = this.innerNodes?.findIndex((n) => n.id == id);
				if (innerNodeIndex > -1) {
					this.node.runningInternalNodeId = innerNodeIndex;
					api.dispatchEvent(new CustomEvent(type, { detail: getEvent(detail, this.node.id + "", this.node) }));
				}
			};
			api.addEventListener(type, handler);
			return handler;
		}

		const executing = handleEvent.call(
			this,
			"executing",
			(d) => d,
			(d, id, node) => id
		);

		const executed = handleEvent.call(
			this,
			"executed",
			(d) => d?.node,
			(d, id, node) => ({ ...d, node: id, merge: !node.resetExecution })
		);

		const onRemoved = node.onRemoved;
		this.node.onRemoved = function () {
			onRemoved?.apply(this, arguments);
			api.removeEventListener("executing", executing);
			api.removeEventListener("executed", executed);
		};

		this.node.refreshComboInNode = (defs) => {
			// Update combo widget options
			for (const widgetName in this.groupData.newToOldWidgetMap) {
				const widget = this.node.widgets.find((w) => w.name === widgetName);
				if (widget?.type === "combo") {
					const old = this.groupData.newToOldWidgetMap[widgetName];
					const def = defs[old.node.type];
					const input = def?.input?.required?.[old.inputName] ?? def?.input?.optional?.[old.inputName];
					if (!input) continue;

					widget.options.values = input[0];

					if (old.inputName !== "image" && !widget.options.values.includes(widget.value)) {
						widget.value = widget.options.values[0];
						widget.callback(widget.value);
					}
				}
			}
		};
	}

	updateInnerWidgets() {
		for (const newWidgetName in this.groupData.newToOldWidgetMap) {
			const newWidget = this.node.widgets.find((w) => w.name === newWidgetName);
			if (!newWidget) continue;

			const newValue = newWidget.value;
			const old = this.groupData.newToOldWidgetMap[newWidgetName];
			let innerNode = this.innerNodes[old.node.index];

			if (innerNode.type === "PrimitiveNode") {
				innerNode.primitiveValue = newValue;
				const primitiveLinked = this.groupData.primitiveToWidget[old.node.index];
				for (const linked of primitiveLinked ?? []) {
					const node = this.innerNodes[linked.nodeId];
					const widget = node.widgets.find((w) => w.name === linked.inputName);

					if (widget) {
						widget.value = newValue;
					}
				}
				continue;
			} else if (innerNode.type === "Reroute") {
				const rerouteLinks = this.groupData.linksFrom[old.node.index];
				if (rerouteLinks) {
					for (const [_, , targetNodeId, targetSlot] of rerouteLinks["0"]) {
						const node = this.innerNodes[targetNodeId];
						const input = node.inputs[targetSlot];
						if (input.widget) {
							const widget = node.widgets?.find((w) => w.name === input.widget.name);
							if (widget) {
								widget.value = newValue;
							}
						}
					}
				}
			}

			const widget = innerNode.widgets?.find((w) => w.name === old.inputName);
			if (widget) {
				widget.value = newValue;
			}
		}
	}

	populatePrimitive(node, nodeId, oldName, i, linkedShift) {
		// Converted widget, populate primitive if linked
		const primitiveId = this.groupData.widgetToPrimitive[nodeId]?.[oldName];
		if (primitiveId == null) return;
		const targetWidgetName = this.groupData.oldToNewWidgetMap[primitiveId]["value"];
		const targetWidgetIndex = this.node.widgets.findIndex((w) => w.name === targetWidgetName);
		if (targetWidgetIndex > -1) {
			const primitiveNode = this.innerNodes[primitiveId];
			let len = primitiveNode.widgets.length;
			if (len - 1 !== this.node.widgets[targetWidgetIndex].linkedWidgets?.length) {
				// Fallback handling for if some reason the primitive has a different number of widgets
				// we dont want to overwrite random widgets, better to leave blank
				len = 1;
			}
			for (let i = 0; i < len; i++) {
				this.node.widgets[targetWidgetIndex + i].value = primitiveNode.widgets[i].value;
			}
		}
		return true;
	}

	populateReroute(node, nodeId, map) {
		if (node.type !== "Reroute") return;

		const link = this.groupData.linksFrom[nodeId]?.[0]?.[0];
		if (!link) return;
		const [, , targetNodeId, targetNodeSlot] = link;
		const targetNode = this.groupData.nodeData.nodes[targetNodeId];
		const inputs = targetNode.inputs;
		const targetWidget = inputs?.[targetNodeSlot]?.widget;
		if (!targetWidget) return;

		const offset = inputs.length - (targetNode.widgets_values?.length ?? 0);
		const v = targetNode.widgets_values?.[targetNodeSlot - offset];
		if (v == null) return;

		const widgetName = Object.values(map)[0];
		const widget = this.node.widgets.find((w) => w.name === widgetName);
		if (widget) {
			widget.value = v;
		}
	}

	populateWidgets() {
		if (!this.node.widgets) return;

		for (let nodeId = 0; nodeId < this.groupData.nodeData.nodes.length; nodeId++) {
			const node = this.groupData.nodeData.nodes[nodeId];
			const map = this.groupData.oldToNewWidgetMap[nodeId] ?? {};
			const widgets = Object.keys(map);

			if (!node.widgets_values?.length) {
				// special handling for populating values into reroutes
				// this allows primitives connect to them to pick up the correct value
				this.populateReroute(node, nodeId, map);
				continue;
			}

			let linkedShift = 0;
			for (let i = 0; i < widgets.length; i++) {
				const oldName = widgets[i];
				const newName = map[oldName];
				const widgetIndex = this.node.widgets.findIndex((w) => w.name === newName);
				const mainWidget = this.node.widgets[widgetIndex];
				if (this.populatePrimitive(node, nodeId, oldName, i, linkedShift) || widgetIndex === -1) {
					// Find the inner widget and shift by the number of linked widgets as they will have been removed too
					const innerWidget = this.innerNodes[nodeId].widgets?.find((w) => w.name === oldName);
					linkedShift += innerWidget?.linkedWidgets?.length ?? 0;
				}
				if (widgetIndex === -1) {
					continue;
				}

				// Populate the main and any linked widget
				mainWidget.value = node.widgets_values[i + linkedShift];
				for (let w = 0; w < mainWidget.linkedWidgets?.length; w++) {
					this.node.widgets[widgetIndex + w + 1].value = node.widgets_values[i + ++linkedShift];
				}
			}
		}
	}

	replaceNodes(nodes) {
		let top;
		let left;

		for (let i = 0; i < nodes.length; i++) {
			const node = nodes[i];
			if (left == null || node.pos[0] < left) {
				left = node.pos[0];
			}
			if (top == null || node.pos[1] < top) {
				top = node.pos[1];
			}

			this.linkOutputs(node, i);
			app.graph.remove(node);
		}

		this.linkInputs();
		this.node.pos = [left, top];
	}

	linkOutputs(originalNode, nodeId) {
		if (!originalNode.outputs) return;

		for (const output of originalNode.outputs) {
			if (!output.links) continue;
			// Clone the links as they'll be changed if we reconnect
			const links = [...output.links];
			for (const l of links) {
				const link = app.graph.links[l];
				if (!link) continue;

				const targetNode = app.graph.getNodeById(link.target_id);
				const newSlot = this.groupData.oldToNewOutputMap[nodeId]?.[link.origin_slot];
				if (newSlot != null) {
					this.node.connect(newSlot, targetNode, link.target_slot);
				}
			}
		}
	}

	linkInputs() {
		for (const link of this.groupData.nodeData.links ?? []) {
			const [, originSlot, targetId, targetSlot, actualOriginId] = link;
			const originNode = app.graph.getNodeById(actualOriginId);
			if (!originNode) continue; // this node is in the group
			originNode.connect(originSlot, this.node.id, this.groupData.oldToNewInputMap[targetId][targetSlot]);
		}
	}

	static getGroupData(node) {
		return (node.nodeData ?? node.constructor?.nodeData)?.[GROUP];
	}

	static isGroupNode(node) {
		return !!node.constructor?.nodeData?.[GROUP];
	}

	static async fromNodes(nodes) {
		// Process the nodes into the stored workflow group node data
		const builder = new GroupNodeBuilder(nodes);
		const res = builder.build();
		if (!res) return;

		const { name, nodeData } = res;

		// Convert this data into a LG node definition and register it
		const config = new GroupNodeConfig(name, nodeData);
		await config.registerType();

		const groupNode = LiteGraph.createNode(`workflow/${name}`);
		// Reuse the existing nodes for this instance
		groupNode.setInnerNodes(builder.nodes);
		groupNode[GROUP].populateWidgets();
		app.graph.add(groupNode);

		// Remove all converted nodes and relink them
		groupNode[GROUP].replaceNodes(builder.nodes);
		return groupNode;
	}
}

function addConvertToGroupOptions() {
	function addConvertOption(options, index) {
		const selected = Object.values(app.canvas.selected_nodes ?? {});
		const disabled = selected.length < 2 || selected.find((n) => GroupNodeHandler.isGroupNode(n));
		options.splice(index + 1, null, {
			content: `Convert to Group Node`,
			disabled,
			callback: async () => {
				return await GroupNodeHandler.fromNodes(selected);
			},
		});
	}

	function addManageOption(options, index) {
		const groups = app.graph.extra?.groupNodes;
		const disabled = !groups || !Object.keys(groups).length;
		options.splice(index + 1, null, {
			content: `Manage Group Nodes`,
			disabled,
			callback: () => {
				new ManageGroupDialog(app).show();
			},
		});
	}

	// Add to canvas
	const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions;
	LGraphCanvas.prototype.getCanvasMenuOptions = function () {
		const options = getCanvasMenuOptions.apply(this, arguments);
		const index = options.findIndex((o) => o?.content === "Add Group") + 1 || options.length;
		addConvertOption(options, index);
		addManageOption(options, index + 1);
		return options;
	};

	// Add to nodes
	const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions;
	LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
		const options = getNodeMenuOptions.apply(this, arguments);
		if (!GroupNodeHandler.isGroupNode(node)) {
			const index = options.findIndex((o) => o?.content === "Outputs") + 1 || options.length - 1;
			addConvertOption(options, index);
		}
		return options;
	};
}

const id = "Comfy.GroupNode";
let globalDefs;
const ext = {
	name: id,
	setup() {
		addConvertToGroupOptions();
	},
	async beforeConfigureGraph(graphData, missingNodeTypes) {
		const nodes = graphData?.extra?.groupNodes;
		if (nodes) {
			await GroupNodeConfig.registerFromWorkflow(nodes, missingNodeTypes);
		}
	},
	addCustomNodeDefs(defs) {
		// Store this so we can mutate it later with group nodes
		globalDefs = defs;
	},
	nodeCreated(node) {
		if (GroupNodeHandler.isGroupNode(node)) {
			node[GROUP] = new GroupNodeHandler(node);
		}
	},
	async refreshComboInNodes(defs) {
		// Re-register group nodes so new ones are created with the correct options
		Object.assign(globalDefs, defs);
		const nodes = app.graph.extra?.groupNodes;
		if (nodes) {
			await GroupNodeConfig.registerFromWorkflow(nodes, {});
		}
	}
};

app.registerExtension(ext);