extends Node
var control_script

var progress_label
var progress_bar
var graph_edit
var console_output
var progress_window
var console_window
var process_successful #tracks if the last run process was successful
var process_info = {} #tracks the data of the currently running process
var process_running := false #tracks if a process is currently running
var process_cancelled = false #checks if the currently running process has been cancelled
var final_output_dir

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	pass


func init(main_node: Node, progresswindow: Window, progresslabel: Label, progressbar: ProgressBar, graphedit: GraphEdit, consolewindow: Window, consoleoutput: RichTextLabel) -> void:
	control_script = main_node
	progress_window = progresswindow
	progress_label = progresslabel
	progress_bar = progressbar
	graph_edit = graphedit
	console_window = consolewindow
	console_output = consoleoutput


	
func run_thread_with_branches():
	process_cancelled = false
	process_successful = true
	progress_bar.value = 0
	progress_label.text = "Initialising Inputs"
	console_window.find_child("KillProcess").disabled = false
	# Detect platform: Determine if the OS is Windows
	var is_windows := OS.get_name() == "Windows"
	
	# Choose appropriate commands based on OS
	var delete_cmd = "del" if is_windows else "rm"
	var rename_cmd = "ren" if is_windows else "mv"
	var path_sep := "/"  # Always use forward slash for paths

	# Get all node connections in the GraphEdit
	var connections = graph_edit.get_connection_list()

	# Prepare data structures for graph traversal
	var graph = {}          # forward adjacency list
	var reverse_graph = {}  # reverse adjacency list (for input lookup)
	var indegree = {}       # used for topological sort
	var all_nodes = {}      # map of node name -> GraphNode reference
	
	#store input nodes for sample rate and stereo matching
	var input_nodes = []
	var nodes_with_sample_rates = []
	var processing_sample_rate = 0 #sample rate that processing is being done at after input file is normalised, if this stays at 0 only synthesis exists in thread and highest value from that should be used
	var processing_bit_depth = 1 #stores the file type and bit-depth in the format used by the copysfx cdp function 1: 16-bit 2: 32-bit int 3: 32-bit float 4: 24-bit
	var intermediate_files = [] # Files to delete later
	var breakfiles = [] #breakfiles to delete later

	log_console("Mapping thread.", true)
	await get_tree().process_frame  # Let UI update

	#Step 0: check thread is valid
	var is_valid = path_exists_through_all_nodes()
	if is_valid == false:
		log_console("[color=#9c2828][b]Error: Valid Thread not found[/b][/color]", true)
		log_console("Threads must contain at least one processing node and a valid path from the Input File or Synthesis node to the Output File.", true)
		await get_tree().process_frame  # Let UI update
		if progress_window.visible:
			progress_window.hide()
		if !console_window.visible:
			console_window.popup_centered()
		return
	else:
		log_console("[color=#638382][b]Valid Thread found[/b][/color]", true)
		await get_tree().process_frame  # Let UI update
		
	# Step 1: Gather nodes from the GraphEdit
	var inputcount = 0 # used for tracking the number of input nodes and trims on input files for progress bar
	for child in graph_edit.get_children():
		if child is GraphNode:
			var includenode = true
			var name = str(child.name)
			all_nodes[name] = child
			if child.has_meta("utility"):
				includenode = false
			else:
				#check if node has inputs
				if child.get_input_port_count() > 0:
					#if it does scan through those inputs
					for i in range(child.get_input_port_count()):
						#check if it can find any valid connections
						var connected = false
						for conn in connections:
							if conn["to_node"] == name and conn["to_port"] == i:
								connected = true
								break
						#if no valid connections are found break the for loop to skip checking other inputs and set include to false
						if connected == false:
							log_console(name + " input is not connected, skipping node.", true)
							includenode = false
							break
				#check if node has outputs
				if child.get_output_port_count() > 0:
					#if it does scan through those outputs
					for i in range(child.get_output_port_count()):
						#check if it can find any valid connections
						var connected = false
						for conn in connections:
							if conn["from_node"] == name and conn["from_port"] == i:
								connected = true
								break
						#if no valid connections are found break the for loop to skip checking other inputs and set include to false
						if connected == false:
							log_console(name + " output is not connected, skipping node.", true)
							includenode = false
							break
								
			if includenode == true:
				graph[name] = []
				reverse_graph[name] = []
				indegree[name] = 0  # Start with zero incoming edges
				if child.get_meta("command") == "inputfile":
					inputcount -= 1
					input_nodes.append(child)
					if child.get_node("AudioPlayer").get_meta("trimfile"):
						inputcount += 1
				#check if node has internal sample rate, e.g. synthesis nodes and add to array for checking if this is set correctly
				if child.has_meta("node_sets_sample_rate") and child.get_meta("node_sets_sample_rate") == true:
					nodes_with_sample_rates.append(child)
	#do calculations for progress bar
	var progress_step
	progress_step = 100 / (graph.size() + 3 + inputcount)

	#check if input file sample rates and bit depths match
	if input_nodes.size() > 1:
		var match_input_files = await match_input_file_sample_rates_and_bit_depths(input_nodes)
		var stereo = []
		if control_script.delete_intermediate_outputs:
			for f in match_input_files[0]:
				intermediate_files.append(f)
		processing_sample_rate = match_input_files[1]
		processing_bit_depth = match_input_files[2]
	elif input_nodes.size() == 1:
		#reset upsampled if it has previously been set on this node
		input_nodes[0].get_node("AudioPlayer").set_meta("upsampled", false)
		#get sample rate and bit-depth so that any synthesis nodes can have the correct sample rate set
		processing_sample_rate = input_nodes[0].get_node("AudioPlayer").get_meta("sample_rate")
		var soundfile_properties = get_soundfile_properties(input_nodes[0].get_node("AudioPlayer").get_meta("inputfile"))
		processing_bit_depth = classify_format(soundfile_properties["format"], soundfile_properties["bitdepth"])
	
	
	#check if the sample rate of synthesis nodes match and if they match any files in the input file nodes
	if (nodes_with_sample_rates.size() > 0 and input_nodes.size() > 0) or nodes_with_sample_rates.size() > 1:
		var sythesis_sample_rates = []
		var highest_synthesis_sample_rate
		var final_synthesis_sample_rate
		var change_synthesis_sample_rate
		
		for node in nodes_with_sample_rates:
			#get the sample rate from the meta and add to an array
			var sample_rate_option_button = node.get_node("samplerate")
			sythesis_sample_rates.append(int(sample_rate_option_button.get_item_text(sample_rate_option_button.selected)))
		
		#Check if all sample rates are the same
		if sythesis_sample_rates.all(func(v): return v == sythesis_sample_rates[0]):
			highest_synthesis_sample_rate = sythesis_sample_rates[0]
			if processing_sample_rate != 0 and processing_sample_rate != highest_synthesis_sample_rate:
				change_synthesis_sample_rate = true
				final_synthesis_sample_rate = processing_sample_rate
		else:
			#if not find the highest sample rate
			change_synthesis_sample_rate = true
			highest_synthesis_sample_rate = sythesis_sample_rates.max()
			if processing_sample_rate != 0 and processing_sample_rate != highest_synthesis_sample_rate:
				final_synthesis_sample_rate = processing_sample_rate
			else:
				final_synthesis_sample_rate = highest_synthesis_sample_rate
		
		if change_synthesis_sample_rate:
			log_console("Sample rate in synthesis nodes do not match the rest of the thread. Adjusting values to " + str(final_synthesis_sample_rate) + "Hz", true)
			for node in nodes_with_sample_rates:
				#get the sample rate from the meta and add to an array
				node.get_node("samplerate").set_meta("adjusted_sample_rate", true)
				node.get_node("samplerate").set_meta("new_sample_rate", final_synthesis_sample_rate)
			
	# Step 2: Build graph relationships from connections
	if process_cancelled:
		progress_label.text = "Thread Stopped"
		log_console("[b]Thread Stopped[/b]", true)
		return
	else:
		progress_label.text = "Building Thread"
	for conn in connections:
		var from = str(conn["from_node"])
		var to = str(conn["to_node"])
		if graph.has(from) and graph.has(to):
			graph[from].append(to)
			reverse_graph[to].append(from)
			indegree[to] += 1  # Count incoming edges
			
	# check for loops
	var has_cycle := detect_cycles(graph, {})  # pass loop_nodes list later
	if has_cycle:
		log_console("[color=#9c2828][b]Error: Thread not valid, Threads cannot contain loops.[/b][/color]", true)
		if progress_window.visible:
			progress_window.hide()
		if !console_window.visible:
			console_window.popup_centered()
		return
		
	# Step 3: Topological sort to get execution order
	var sorted = []  # Sorted list of node names
	var queue = []   # Queue of nodes with 0 indegree

	for node in graph.keys():
		if indegree[node] == 0:
			queue.append(node)

	while not queue.is_empty():
		var current = queue.pop_front()
		sorted.append(current)
		for neighbor in graph[current]:
			indegree[neighbor] -= 1
			if indegree[neighbor] == 0:
				queue.append(neighbor)

	# If not all nodes were processed, there's a cycle
	#if sorted.size() != graph.size():
		#log_console("[color=#9c2828][b]Error: Thread not valid[/b][/color]", true)
		#log_console("Threads cannot contain loops.", true)
		#return
	progress_bar.value = progress_step
	# Step 4: Start processing audio

	# Dictionary to keep track of each node's output file
	var output_files = {}
	var process_count = 0

	#var current_infile

	# Iterate over the processing nodes in topological order
	for node_name in sorted:
		var node = all_nodes[node_name]
		if process_cancelled:
			progress_label.text = "Thread Stopped"
			log_console("[b]Thread Stopped[/b]", true)
			break
		else:
			progress_label.text = "Running process: " + node.get_title()
		# Find upstream nodes connected to the current node
		# Build an array of all inlet connections
		var input_connections := []
		for conn in connections:
			if conn["to_node"] == node_name:
				input_connections.append(conn)
		input_connections.sort_custom(func(a, b): return a["to_port"] < b["to_port"])
		
		#build a dictionary with all inputs sorted by inlet number
		var inlet_inputs = {}

		for conn in input_connections:
			var inlet_idx = conn["to_port"]
			var upstream_node = conn["from_node"]
			if output_files.has(upstream_node):
				if not inlet_inputs.has(inlet_idx):
					inlet_inputs[inlet_idx] = []
				inlet_inputs[inlet_idx].append(output_files[upstream_node])

		# Merge inputs if inlet has more than one input and build infile dictionary
		var current_infiles = {} #dictionary to store input files by inlet number

		for inlet_idx in inlet_inputs.keys():
			var files = inlet_inputs[inlet_idx]
			if files.size() > 1: #if more than one file mix them together
				var runmerge = await merge_many_files(inlet_idx, process_count, files)
				var merge_output = runmerge[0] #mixed output file name
				var converted_files = runmerge[1] #intermediate files created from merge

				current_infiles[inlet_idx] = merge_output #input filename added to dictionary sorted by inlet number
				
				#add intermediate files to delete list if toggled
				if control_script.delete_intermediate_outputs:
					intermediate_files.append(merge_output)
					for f in converted_files:
						intermediate_files.append(f)
			elif files.size() == 1:
				current_infiles[inlet_idx] = files[0] #only one file, do not merge add to dictionary
		
		#if the dictionary has more than one entry there is more than one inlet and files need to be matched
		#however this should only be done to nodes with audio files rather than pvoc nodes
		if current_infiles.size() > 1 and node.get_slot_type_left(0) == 0:
			#check all files in dictionary have the same sample rate and channel count and fix if not
			var all_files = current_infiles.values()
			
			var match_channels = await match_file_channels(0, process_count, all_files)
			var matched_files = match_channels[0]
			
			#add intermediate files
			if control_script.delete_intermediate_outputs:
				for f in match_channels[1]:
					intermediate_files.append(f)
			
			#replace files in dictionary with matched files
			var idx = 0
			for key in current_infiles.keys():
				current_infiles[key] = matched_files[idx]
				idx += 1
		
		#check if node is some form of input node
		if node.get_input_port_count() == 0:
			if node.get_meta("command") == "inputfile":
				var loadedfile
				#get the inputfile from the nodes meta
				if node.get_node("AudioPlayer").get_meta("upsampled"):
					loadedfile = node.get_node("AudioPlayer").get_meta("upsampled_file")
				else:
					loadedfile = node.get_node("AudioPlayer").get_meta("inputfile")
				#get wether trim has been enabled
				var trimfile = node.get_node("AudioPlayer").get_meta("trimfile")
				
				#if trim is enabled trim the file
				if trimfile == true:
					#get the start and end points
					var start = node.get_node("AudioPlayer").get_meta("trimpoints")[0]
					var end = node.get_node("AudioPlayer").get_meta("trimpoints")[1]
					
					if process_cancelled:
						#exit out of process if cancelled
						progress_label.text = "Thread Stopped"
						log_console("[b]Thread Stopped[/b]", true)
						return
					else:
						progress_label.text = "Trimming input audio"
					await run_command(control_script.cdpprogs_location + "/sfedit", ["cut", "1", loadedfile, "%s_%d_input_trim.wav" % [Global.outfile, process_count], str(start), str(end)])
					
					output_files[node_name] =  "%s_%d_input_trim.wav" % [Global.outfile, process_count]
					
					# Mark trimmed file for cleanup if needed
					if control_script.delete_intermediate_outputs:
						intermediate_files.append("%s_%d_input_trim.wav" % [Global.outfile, process_count])
					progress_bar.value += progress_step
				else:
					#if trim not enabled pass the loaded file
					output_files[node_name] =  loadedfile
					
				process_count += 1
			else: #not an audio file must be synthesis
				var slider_data = _get_slider_values_ordered(node)
				var makeprocess = await make_process(node, process_count, [], slider_data)
				# run the command
				await run_command(makeprocess[0], makeprocess[3])
				await get_tree().process_frame
				var output_file = makeprocess[1]
				
				#check if bitdepth matches other files in thread and convert if needed
				var soundfile_properties = get_soundfile_properties(output_file)
				if processing_bit_depth != classify_format(soundfile_properties["format"], soundfile_properties["bitdepth"]):
					var bit_convert_output = output_file.get_basename() + "_bit_depth_convert.wav"
					await run_command(control_script.cdpprogs_location + "/copysfx", ["-h0", "-s" + str(processing_bit_depth), output_file, bit_convert_output])
					#store converted output file path for this node
					output_files[node_name] = bit_convert_output
					#mark for cleanup if needed
					if control_script.delete_intermediate_outputs:
						intermediate_files.append(bit_convert_output)
				else:
					# Store original output file path for this node
					output_files[node_name] = output_file

				# Mark file for cleanup if needed
				if control_script.delete_intermediate_outputs:
					for file in makeprocess[2]:
						breakfiles.append(file)
					intermediate_files.append(output_file)
					
				process_count += 1
		else:
			# Build the command for the current node's audio processing
			var slider_data = _get_slider_values_ordered(node)
			
			if node.get_slot_type_right(0) == 1: #detect if process outputs pvoc data
				if is_pvoc_stereo(current_infiles): #check if infiles contain an array meaning at least one input pvoc process has be processed in dual mono mode
					var split_files = await process_dual_mono_pvoc(current_infiles, node, process_count, slider_data)
					var pvoc_stereo_files = split_files[0]
								
					# Mark file for cleanup if needed
					if control_script.delete_intermediate_outputs:
						for file in split_files[1]:
							breakfiles.append(file)
						for file in pvoc_stereo_files:
							intermediate_files.append(file)
							
					process_count += 1
						
					output_files[node_name] = pvoc_stereo_files
				else:
					var input_stereo = await is_stereo(current_infiles.values()[0])
					if input_stereo == true: 
						#audio file is stereo and needs to be split for pvoc processing
						var pvoc_stereo_files = []

						##Split stereo to c1/c2 and process
						var split_files = await stereo_split_and_process(current_infiles.values(), node, process_count, slider_data)
						pvoc_stereo_files = split_files[0]
							
						# Mark file for cleanup if needed
						if control_script.delete_intermediate_outputs:
							for file in split_files[1]:
								breakfiles.append(file)
							for file in pvoc_stereo_files:
								intermediate_files.append(file)
						
						#Delete c1 and c2 because they can be in the wrong folder and if the same infile is used more than once
						#with this stereo process CDP will throw errors in the console even though its fine
						var files_to_delete = split_files[2] + split_files[3]
						for file in files_to_delete:
							if is_windows:
								file = file.replace("/", "\\")
							await run_command(delete_cmd, [file])
						
						#advance process count to match the advancement in the stereo_split_and_process function
						process_count += 1
						
						# Store output file path for this node
						output_files[node_name] = pvoc_stereo_files
						
					else: 
						#input file is mono run through process
						var makeprocess = await make_process(node, process_count, current_infiles.values(), slider_data)
						# run the command
						await run_command(makeprocess[0], makeprocess[3])
						await get_tree().process_frame
						var output_file = makeprocess[1]

						# Store output file path for this node
						output_files[node_name] = output_file

						# Mark file for cleanup if needed
						if control_script.delete_intermediate_outputs:
							for file in makeprocess[2]:
								breakfiles.append(file)
							intermediate_files.append(output_file)

			# Increase the process step count
				process_count += 1
				
			else: 
				#Process outputs audio
				#check if this is the last pvoc process in a stereo processing chain and check if infile is an array meaning that the last pvoc process was run in dual mono mode
				if node.get_meta("command") == "pvoc_synth" and is_pvoc_stereo(current_infiles):
					var split_files = await process_dual_mono_pvoc(current_infiles, node, process_count, slider_data)
					var pvoc_stereo_files = split_files[0]
								
					# Mark file for cleanup if needed
					if control_script.delete_intermediate_outputs:
						for file in split_files[1]:
							breakfiles.append(file)
						for file in pvoc_stereo_files:
							intermediate_files.append(file)
							
					process_count += 1
						
						
					#interleave left and right
					var output_file = Global.outfile.get_basename() + str(process_count) + "_interleaved.wav"
					await run_command(control_script.cdpprogs_location + "/submix", ["interleave", pvoc_stereo_files[0], pvoc_stereo_files[1], output_file])
					# Store output file path for this node
					output_files[node_name] = output_file
					
					# Mark file for cleanup if needed
					if control_script.delete_intermediate_outputs:
						intermediate_files.append(output_file)
				elif node.get_meta("command") == "preview":
					var preview_audioplayer = node.get_child(1)
					var preview_file = current_infiles.values()[0]
					preview_audioplayer._on_file_selected(preview_file)
					if preview_file in intermediate_files:
						intermediate_files.erase(preview_file)
				else:
					#Detect if input file is mono or stereo
					var input_stereo = await is_stereo(current_infiles.values()[0])
					#var input_stereo = true #bypassing stereo check just for testing need to reimplement
					if input_stereo == true:
						if node.get_meta("stereo_input") == true: #audio file is stereo and process is stereo, run file through process
							#current_infile = current_infiles.values()
							var makeprocess = await make_process(node, process_count, current_infiles.values(), slider_data)
							# run the command
							await run_command(makeprocess[0], makeprocess[3])
							await get_tree().process_frame
							var output_file = makeprocess[1]
							
							# Store output file path for this node
							output_files[node_name] = output_file

							# Mark file for cleanup if needed
							if control_script.delete_intermediate_outputs:
								for file in makeprocess[2]:
									breakfiles.append(file)
								intermediate_files.append(output_file)

						else: #audio file is stereo and process is mono, split stereo, process and recombine
							##Split stereo to c1/c2 and process
							var split_files = await stereo_split_and_process(current_infiles.values(), node, process_count, slider_data)
							var dual_mono_output = split_files[0]
								
							# Mark file for cleanup if needed
							if control_script.delete_intermediate_outputs:
								for file in split_files[1]:
									breakfiles.append(file)
								for file in dual_mono_output:
									intermediate_files.append(file)
							
							#Delete c1 and c2 because they can be in the wrong folder and if the same infile is used more than once
							#with this stereo process CDP will throw errors in the console even though its fine
							var files_to_delete = split_files[2] + split_files[3]
							for file in files_to_delete:
								if is_windows:
									file = file.replace("/", "\\")
								await run_command(delete_cmd, [file])
							
							#advance process count to match the advancement in the stereo_split_and_process function
							process_count += 1
							
							
							var output_file = Global.outfile.get_basename() + str(process_count) + "_interleaved.wav"
							await run_command(control_script.cdpprogs_location + "/submix", ["interleave", dual_mono_output[0], dual_mono_output[1], output_file])
							
							# Store output file path for this node
							output_files[node_name] = output_file

							# Mark file for cleanup if needed
							if control_script.delete_intermediate_outputs:
								intermediate_files.append(output_file)

					else: #audio file is mono, run through the process
						var makeprocess = await make_process(node, process_count, current_infiles.values(), slider_data)
						# run the command
						await run_command(makeprocess[0], makeprocess[3])
						await get_tree().process_frame
						var output_file = makeprocess[1]
						

						# Store output file path for this node
						output_files[node_name] = output_file

						# Mark file for cleanup if needed
						if control_script.delete_intermediate_outputs:
							for file in makeprocess[2]:
								breakfiles.append(file)
							intermediate_files.append(output_file)

				# Increase the process step count
				process_count += 1
			progress_bar.value += progress_step
	# FINAL OUTPUT STAGE

	# Collect all nodes that are connected to the outputfile node
	if process_cancelled:
		progress_label.text = "Thread Stopped"
		log_console("[b]Thread Stopped[/b]", true)
		return
	else:
		progress_label.text = "Finalising output"
	var output_inputs := []
	for conn in connections:
		var to_node = str(conn["to_node"])
		if all_nodes.has(to_node) and all_nodes[to_node].get_meta("command") == "outputfile":
			output_inputs.append(str(conn["from_node"]))

	# List to hold the final output files to be merged (if needed)
	var final_outputs := []
	for node_name in output_inputs:
		if output_files.has(node_name):
			final_outputs.append(output_files[node_name])

	# If multiple outputs go to the outputfile node, merge them
	if final_outputs.size() > 1:
		var runmerge = await merge_many_files(0, process_count, final_outputs)
		final_output_dir = runmerge[0]
		var converted_files = runmerge[1]
		
		if control_script.delete_intermediate_outputs:
			for f in converted_files:
				intermediate_files.append(f)


	# Only one output, no merge needed
	elif final_outputs.size() == 1:
		var single_output = final_outputs[0]
		final_output_dir = single_output
		intermediate_files.erase(single_output)
	progress_bar.value += progress_step
	# CLEANUP: Delete intermediate files after processing, rename final output and reset upsampling meta
	if process_cancelled:
		progress_label.text = "Thread Stopped"
		log_console("[b]Thread Stopped[/b]", true)
		return
	else:
		log_console("Cleaning up intermediate files.", true)
		progress_label.text = "Cleaning up"
	for file_path in intermediate_files:
		# Adjust file path format for Windows if needed
		var fixed_path = file_path
		if is_windows:
			fixed_path = fixed_path.replace("/", "\\")
		await run_command(delete_cmd, [fixed_path])
		await get_tree().process_frame
	#delete break files 
	for file_path in breakfiles:
		# Adjust file path format for Windows if needed
		var fixed_path = file_path
		if is_windows:
			fixed_path = fixed_path.replace("/", "\\")
		await run_command(delete_cmd, [fixed_path])
		await get_tree().process_frame
		
		
	var final_filename = "%s.wav" % Global.outfile
	var final_output_dir_fixed_path = final_output_dir
	if is_windows:
		final_output_dir_fixed_path = final_output_dir_fixed_path.replace("/", "\\")
		await run_command(rename_cmd, [final_output_dir_fixed_path, final_filename.get_file()])
	else:
		await run_command(rename_cmd, [final_output_dir_fixed_path, "%s.wav" % Global.outfile])
	final_output_dir = Global.outfile + ".wav"
	
	control_script.output_audio_player.play_outfile(final_output_dir)
	Global.cdpoutput = final_output_dir
	progress_bar.value = 100.0
	var interface_settings = ConfigHandler.load_interface_settings() #checks if close console is enabled and closes console on a success
	progress_window.hide()
	progress_bar.value = 0
	progress_label.text = ""
	console_window.find_child("KillProcess").disabled = true
	if interface_settings.auto_close_console and process_successful == true:
		console_window.hide()

func stereo_split_and_process(files: Array, node: Node, process_count: int, slider_data: Array) -> Array:
	var dual_mono_output:= []
	var left:= []
	var right:= []
	var intermediate_files:= []
	
	for file in files:
		await run_command(control_script.cdpprogs_location + "/housekeep",["chans", "2", file])
		left.append(file.get_basename() + "_%s.%s" % ["c1", file.get_extension()])
		right.append(file.get_basename() + "_%s.%s" % ["c2", file.get_extension()])
	
	#loop through the left and right arrays and make and run the process for each of them
	for channel in [left, right]:
		var makeprocess = await make_process(node, process_count, channel, slider_data)
		# run the command
		await run_command(makeprocess[0], makeprocess[3])
		await get_tree().process_frame
		var output_file = makeprocess[1]
		dual_mono_output.append(output_file)
		for file in makeprocess[2]:
			intermediate_files.append(file)
		
		#advance process count to maintain unique file names
		process_count += 1
		
	#return the two output files, any breakfiles generated and the split files for deletion
	return [dual_mono_output, intermediate_files, left, right]
	
func process_dual_mono_pvoc(current_infiles: Dictionary, node: Node, process_count: int, slider_data: Array) -> Array:
	match_pvoc_channels(current_infiles) #normalise dictionary to ensure that all entries are dual mono (any mono only processes are duplicated to both left and right)
	
	var infiles_left = []
	var infiles_right = []
	var pvoc_stereo_files = []
	var intermediate_files = []
	
	# extract left and right infiles from dictionary
	for value in current_infiles.values():
		infiles_left.append(value[0])
		infiles_right.append(value[1])
		
	for infiles in [infiles_left, infiles_right]:
		var makeprocess = await make_process(node, process_count, infiles, slider_data)
		# run the command
		await run_command(makeprocess[0], makeprocess[3])
		await get_tree().process_frame
		var output_file = makeprocess[1]
		pvoc_stereo_files.append(output_file)
		for file in makeprocess[2]:
			intermediate_files.append(file)
		
		#advance process count to maintain unique file names
		process_count += 1
		
	return [pvoc_stereo_files, intermediate_files]
	
func is_stereo(file: String) -> bool:
	var soundfile_properties = get_soundfile_properties(file)
	if soundfile_properties["channels"] == 2:
		return true
	else:
		return false

func is_pvoc_stereo(current_infiles: Dictionary) -> bool:
	for value in current_infiles.values():
		if value is Array:
			return true
	return false

## Returns properties of a WAV file as a Dictionary:
## {
##   "format": 1 or 3,
##   "channels": number of channels,
##   "samplerate": sample rate in Hz,
##   "bitdepth": bits per sample,
##   "duration": length in seconds
## }
func get_soundfile_properties(file: String) -> Dictionary:
	var soundfile_properties:= {
		"format": 0,
		"channels": 0,
		"samplerate": 0,
		"bitdepth": 0,
		"duration": 0.0
	
	}
	
	#open the audio file
	var f = FileAccess.open(file, FileAccess.READ)
	if f == null:
		log_console("Could not find file: " + file, true)
		return soundfile_properties  # couldn't open
	
	#Skip the RIFF header (12 bytes: "RIFF", file size, "WAVE")
	f.seek(12)
	
	var audio_chunk_size = 0
	
	#read through file until end of file if needed
	while f.get_position() + 8 <= f.get_length():
		#read the 4 byte chunk id to identify what this chunk is
		var chunk_id = f.get_buffer(4).get_string_from_ascii() 
		#read how big this chunk is
		var chunk_size = f.get_32()
		
		if chunk_id == "fmt ":
			#found the format chunk
			#fmt chunk layout:
			#2 bytes: Audio format (1 = PCM, 3 = IEEE float, etc.)
			#2 bytes: Number of channels (1 = mono, 2 = stereo, ...)
			#4 bytes: Sample rate
			#4 bytes: Byte rate
			#2 bytes: Block align
			#2 bytes: Bits per sample
			#potentially misc other stuff depending on format
			soundfile_properties["format"] = f.get_16() #format 2 bytes: 1 = int PCM, 3 = float
			soundfile_properties["channels"] = f.get_16() #num of channels 2 bytes
			soundfile_properties["samplerate"] = f.get_32() #sample rate 4 bytes
			f.seek(f.get_position() + 6)
			soundfile_properties["bitdepth"] = f.get_16() #bitdepth 2 bytes
			
			#check if we have already found the data chunk (not likely) and break the loop
			if audio_chunk_size > 0:
				f.close()
				break
			#skip to the end of the fmt chunk - max protects against skipping weirdly if wav is malformed and we have already moved too far into the file
			f.seek(f.get_position() + (max(chunk_size - 16, 0)))
		elif chunk_id == "data":
			#this is where the audio is stored
			audio_chunk_size = chunk_size
			#check if we have already found the fmt chunk and break loop
			if soundfile_properties["format"] > 0:
				f.close()
				break
			#skip the rest of the chunk
			f.seek(f.get_position() + chunk_size)
		else:
			#don't care about any other data in the file skip it
			f.seek(f.get_position() + chunk_size)
			
	#close the file
	f.close()
	
	if audio_chunk_size > 0 and soundfile_properties["channels"] > 0 and soundfile_properties["bitdepth"] > 0 and soundfile_properties["samplerate"] > 0:
		#(channels * bitdepth) / 8 - div 8 to convet bits to bytes
		var block_align = int((soundfile_properties["channels"] * soundfile_properties["bitdepth"]) / 8)
		#number of frames = size of audio chunk / block size in bytes
		var num_frames = int(audio_chunk_size / block_align)
		#length in seconds = number of frames / sample rate
		soundfile_properties.duration = (num_frames) / soundfile_properties["samplerate"]
	else:
		#something = 0 and something has gone wrong
		log_console("No valid fmt chunk found in wav file, unable to establish, format, channel count, samplerate or bit-depth", true)
		for key in soundfile_properties:
			#normalise dictionary to 0 so code can detect errors later even if some values have ended up in the dictionary
			soundfile_properties[key] = 0
		return soundfile_properties #no fmt chunk found, invalid wav file
		
	return soundfile_properties

func merge_many_files(inlet_id: int, process_count: int, input_files: Array) -> Array:
	var merge_output = "%s_merge_%d_%d.wav" % [Global.outfile.get_basename(), inlet_id, process_count]
	var converted_files := []  # Track any mono->stereo converted files or upsampled files
	
	#check if there are a mix of mono and stereo files and interleave mono files if required
	var match_channels = await match_file_channels(inlet_id, process_count, input_files)
	input_files = match_channels[0]
	converted_files += match_channels[1]

	# Merge all input files (converted or original)
	log_console("Mixing files to combined input.", true)
	var command := ["mergemany"]
	command += input_files
	command.append(merge_output)
	await run_command(control_script.cdpprogs_location + "/submix", command)
	
	if process_successful == false:
		log_console("Failed to to merge files to" + merge_output, true)
	
	return [merge_output, converted_files]
	
func match_input_file_sample_rates_and_bit_depths(input_nodes: Array) -> Array:
	var sample_rates := []
	var input_files := [] #used to track input files so that the same file is not upsampled more than once should it be loaded into more than one input node
	var converted_files := []
	var highest_sample_rate
	var bit_depths:= []
	var file_types:= []
	var highest_bit_depth
	var int_float
	var final_format
	
	#get the sample rate, bit depth and file type (int/float) for each file and add to arrays
	for node in input_nodes:
		var soundfile_props = get_soundfile_properties(node.get_node("AudioPlayer").get_meta("inputfile"))
		file_types.append(soundfile_props["format"])
		sample_rates.append(soundfile_props["samplerate"])
		bit_depths.append(soundfile_props["bitdepth"])
		#set upsampled meta to false to allow for repeat runs of thread
		node.get_node("AudioPlayer").set_meta("upsampled", false)
	
	#Check if all sample rates are the same
	if sample_rates.all(func(v): return v == sample_rates[0]):
		highest_sample_rate = sample_rates[0]
		pass
	else:
		#if not find the highest sample rate
		highest_sample_rate = sample_rates.max()
		log_console("Different sample rates found in input files, upsampling files to match highest sample rate (" + str(highest_sample_rate) + "Hz) before processing.", true)
		#move through all input files and compare match their index to the sample_rate array
		for node in input_nodes:
			#check if sample rate of current node is less than the highest sample rate
			if node.get_node("AudioPlayer").get_meta("sample_rate") < highest_sample_rate:
				var input_file = node.get_node("AudioPlayer").get_meta("inputfile")
				#up sample it to the highest sample rate if so
				var upsample_output = Global.outfile + "_" + input_file.get_file().get_slice(".wav", 0) + "_" + str(highest_sample_rate) + ".wav"
				#check if file has previously been upsampled and if not upsample it
				if !input_files.has(input_file):
					input_files.append(input_file)
					await run_command(control_script.cdpprogs_location + "/housekeep", ["respec", "1", input_file, upsample_output, str(highest_sample_rate)])
					#add to converted files for cleanup if needed
					converted_files.append(upsample_output)
				node.get_node("AudioPlayer").set_meta("upsampled", true)
				node.get_node("AudioPlayer").set_meta("upsampled_file", upsample_output)
				
	input_files = [] #clear input files array for reuse with bitdepths
	
	#check if all file types and bit-depths are the same
	if file_types.all(func(v): return v == sample_rates[0]) and bit_depths.all(func(v): return v == sample_rates[0]):
		highest_bit_depth = bit_depths[0]
		int_float = file_types[0]
		#convert this to the value cdp uses in copysfx for potential use with synthesis nodes later
		final_format = classify_format(int_float, highest_bit_depth)
	else:
		highest_bit_depth = bit_depths.max()
		int_float = file_types.max()
		#convert this to the value cdp needs to convert file types using copysfx
		final_format = classify_format(int_float, highest_bit_depth)
		log_console("Different bit-depths found in input files, converting files to match highest bit-depth (" + str(highest_bit_depth) + "-bit) before processing.", true)
		#move through all input file nodes and compare them to the highest bit depth and file type
		var index = 0
		for node in input_nodes:
			if classify_format(file_types[index], bit_depths[index]) != final_format:
				var input_file
				#check if input file has already been upsampled and respec that file instead
				if node.get_node("AudioPlayer").get_meta("upsampled") == true:
					input_file = node.get_node("AudioPlayer").get_meta("upsampled_file")
				else:
					input_file = node.get_node("AudioPlayer").get_meta("inputfile")
				#build unique output name
				var bit_convert_output = Global.outfile + "_" + input_file.get_file().get_slice(".wav", 0) + "_" + str(highest_bit_depth) + "-bit" + ".wav"
				#check if this file has already been respeced (two input nodes with the same file loaded for some reason)
				if !input_files.has(input_file):
					input_files.append(input_file)
					await run_command(control_script.cdpprogs_location + "/copysfx", ["-h0", "-s" + str(final_format), input_file, bit_convert_output])
					#add to converted files for cleanup if needed
					converted_files.append(bit_convert_output)
				node.get_node("AudioPlayer").set_meta("upsampled", true)
				node.get_node("AudioPlayer").set_meta("upsampled_file", bit_convert_output)
			index += 1
	
	return [converted_files, highest_sample_rate, final_format]

func classify_format(file_type: int, bit_depth: int) -> int:
	#takes the bitdepth and file type (int/float) of a wav file and outputs a number that can be used by the cdp process copysfx to respec a files bit-depth
	match [file_type, bit_depth]:
		[1, 16]:
			return 1
		[1, 32]:
			return 2
		[3, 32]:
			return 3
		[1, 24]:
			return 4
		_:
			return -1

#need to remove this function as not needed
#func match_file_sample_rates(inlet_id: int, process_count: int, input_files: Array) -> Array:
	#var sample_rates := []
	#var converted_files := []
	#
	##Get all sample rates
	#for f in input_files:
		#var samplerate = await get_samplerate(f)
		#sample_rates.append(samplerate)
	#
	##Check if all sample rates are the same
	#if sample_rates.all(func(v): return v == sample_rates[0]):
		#pass
	#else:
		#log_console("Different sample rates found, upsampling files to match highest current sample rate before processing.", true)
		##if not find the highest sample rate
		#var highest_sample_rate = sample_rates.max()
		#var index = 0
		##move through all input files and compare match their index to the sample_rate array
		#for f in input_files:
			##check if sample rate of current file is less than the highest sample rate
			#if sample_rates[index] < highest_sample_rate:
				##up sample it to the highest sample rate if so
				#var upsample_output = Global.outfile + "_" + str(inlet_id) + "_" + str(process_count) + f.get_file().get_slice(".wav", 0) + "_" + str(highest_sample_rate) + ".wav"
				#await run_command(control_script.cdpprogs_location + "/housekeep", ["respec", "1", f, upsample_output, str(highest_sample_rate)])
				##replace the file in the input_file index with the new upsampled file
				#input_files[index] = upsample_output
				#converted_files.append(upsample_output)
				#
			#index += 1
	#return [input_files, converted_files]
	
func match_file_channels(inlet_id: int, process_count: int, input_files: Array) -> Array:
	var converted_files := []
	var channel_counts := []
	
	# Check each file's channel count and build channel count array
	for f in input_files:
		var stereo = await is_stereo(f)
		channel_counts.append(stereo)

	# Check if there is a mix of mono and stereo files
	if channel_counts.has(true) and channel_counts.has(false):
		log_console("Mix of mono and stereo files found, interleaving mono files to stereo before mixing.", true)
		var index = 0
		for f in input_files:
			if channel_counts[index] == false: #file is mono
				var stereo_file = Global.outfile + "_" + str(inlet_id) + "_" + str(process_count) + f.get_file().get_slice(".wav", 0) + "_stereo.wav"
				await run_command(control_script.cdpprogs_location + "/submix", ["interleave", f, f, stereo_file])
				if process_successful == false:
					log_console("Failed to interleave mono file: %s" % f, true)
				else:
					converted_files.append(stereo_file)
					input_files[index] = stereo_file
			index += 1

	return [input_files, converted_files]
	
func match_pvoc_channels(dict: Dictionary) -> void:
	#work through dictionary of files and make all entries dual arrays for stereo pvoc processing
	for key in dict.keys():
		var value = dict[key]
		if value is String:
			dict[key] = [value, value]
			
func _get_slider_values_ordered(node: Node) -> Array:
	var results := []
	for child in node.get_children():
		if child is Range:
			var flag = child.get_meta("flag") if child.has_meta("flag") else ""
			var time = child.get_meta("time")
			var brk_data = []
			var min_slider = child.min_value
			var max_slider = child.max_value
			var exp = child.exp_edit
			if child.has_meta("brk_data"):
				brk_data = child.get_meta("brk_data")
			results.append(["slider", flag, child.value, time, brk_data, min_slider, max_slider, exp])
		elif child is CheckButton:
			var flag = child.get_meta("flag") if child.has_meta("flag") else ""
			results.append(["checkbutton", flag, child.button_pressed])
		elif child is OptionButton:
			var flag = child.get_meta("flag") if child.has_meta("flag") else ""
			var value = child.get_item_text(child.selected)
			#check if there has been a sample rate mismatch in the thread and adjust the this parameter to match the threads sample rate
			if child.has_meta("adjusted_sample_rate") and child.get_meta("adjusted_sample_rate"):
				value = str(child.get_meta("new_sample_rate"))
				child.set_meta("adjusted_sample_rate", false)
			results.append(["optionbutton", flag, value])
		#call this function recursively to find any nested sliders in scenes
		if child.get_child_count() > 0:
			var nested := _get_slider_values_ordered(child)
			results.append_array(nested)
	return results



func make_process(node: Node, process_count: int, current_infile: Array, slider_data: Array) -> Array:
	var args:= []
	var command
	var cleanup = []
	
	# Determine output extension: .wav or .ana based on the node's slot type
	var extension = ".wav" if node.get_slot_type_right(0) == 0 else ".ana"

	# Construct output filename for this step
	var output_file = "%s_%d%s" % [Global.outfile.get_basename(), process_count, extension]
	
	#special case for morph glide as it requires spec grab to have been run first
	if node.get_meta("command") == "morph_glide":
		#get slider values nothing else needed
		var window1 = slider_data[0][2]
		var window2 = slider_data[1][2]
		var duration = slider_data[2][2]
		
		#get length of the two input files
		var soundfile_1_props = get_soundfile_properties(current_infile[0])
		var infile_1_length = soundfile_1_props["duration"]
		var soundfile_2_props = get_soundfile_properties(current_infile[1])
		var infile_2_length = soundfile_2_props["duration"]
		if window1 == 100:
			#if slider is set to 100% default to 10 milliseconds before the end of the file to stop cdp moaning about rounding errors
			window1 = infile_1_length - 0.1
		else:
			window1 = infile_1_length * (window1 / 100) #calculate percentage time of the input file
		if window2 == 100:
			#if slider is set to 100% default to 10 milliseconds before the end of the file to stop cdp moaning about rounding errors
			window2 = infile_2_length - 0.1
		else:
			window2 = infile_2_length * (window2 / 100) #calculate percentage time of the input file
			
		#run spec grab to extract the chosen windows
		var window1_outfile = "%s_%d_%s%s" % [Global.outfile.get_basename(), process_count, "window1", extension]
		run_command("%s/%s" %[control_script.cdpprogs_location, "spec"], ["grab", current_infile[0], window1_outfile, str(window1)])
		cleanup.append(window1_outfile)
		var window2_outfile = "%s_%d_%s%s" % [Global.outfile.get_basename(), process_count, "window2", extension]
		run_command("%s/%s" %[control_script.cdpprogs_location, "spec"], ["grab", current_infile[1], window2_outfile, str(window2)])
		cleanup.append(window2_outfile)
		
		#build actual glide command
		command = "%s/%s" %[control_script.cdpprogs_location, "morph"]
		args = ["glide", window1_outfile, window2_outfile, output_file, duration]
	else:
		# Get the command name from metadata
		var command_name = str(node.get_meta("command"))
		if command_name.find("_") != -1:
			command_name = command_name.split("_", true, 1)
			command = "%s/%s" %[control_script.cdpprogs_location, command_name[0]]
			args = command_name[1].split("_", true, 1)
		else:
			command = "%s/%s" %[control_script.cdpprogs_location, command_name]
			
		if current_infile.size() > 0:
			#check if input is empty, e.g. synthesis nodes, otherwise append input file to arguments
			for file in current_infile:
				args.append(file)
		args.append(output_file)

		

		# Append parameter values from the sliders, include flags if present
		var slider_count = 0
		for entry in slider_data:
			if entry[0] == "slider":
				var flag = entry[1]
				var value = entry[2]
				#if value == int(value):
					#value = int(value)
				var time = entry[3] #checks if slider is a time percentage slider
				var brk_data = entry[4]
				var min_slider = entry[5]
				var max_slider = entry[6]
				var exp = entry[7]
				if brk_data.size() > 0: #if breakpoint data is present on slider
					#Sort all points by time
					var sorted_brk_data = []
					sorted_brk_data = brk_data.duplicate()
					sorted_brk_data.sort_custom(sort_points)
					
					var calculated_brk = []
					
					#get length of input file in seconds
					var infile_length = 1 #set infile length to dummy value just incase it does get used where it shouldn't to avoid crashes
					if current_infile.size() > 0:
						var soundfile_props = get_soundfile_properties(current_infile[0])
						infile_length = soundfile_props["duration"]
					
					#scale values from automation window to the right length for file and correct slider values
					#if node has an output duration then breakpoint files should be x = outputduration y= slider value else x=input duration, y=value
					if node.has_meta("outputduration"):
						for i in range(sorted_brk_data.size()):
							var point = sorted_brk_data[i]
							var new_x = float(node.get_meta("outputduration")) * (point.x / 700) #output time
							if i == sorted_brk_data.size() - 1: #check if this is last automation point
								new_x = float(node.get_meta("outputduration")) + 0.1  # force last point's x to infile_length + 100ms to make sure the file is defo over
							var new_y
							#check if slider is exponential and scale automation
							if exp:
								new_y = remap_y_to_log_scale(point.y, 0.0, 255.0, min_slider, max_slider)
							else:
								new_y = remap(point.y, 255, 0, min_slider, max_slider) #slider value
							if time: #check if this is a time slider and convert to percentage of input file
								new_y = infile_length * (new_y / 100)
							calculated_brk.append(Vector2(new_x, new_y))
					else:
						for i in range(sorted_brk_data.size()):
							var point = sorted_brk_data[i]
							var new_x = infile_length * (point.x / 700) #time
							if i == sorted_brk_data.size() - 1: #check if this is last automation point
								new_x = infile_length + 0.1  # force last point's x to infile_length + 100ms to make sure the file is defo over
							var new_y
							#check if slider is exponential and scale automation
							if exp:
								new_y = remap_y_to_log_scale(point.y, 0.0, 255.0, min_slider, max_slider)
							else:
								new_y = remap(point.y, 255, 0, min_slider, max_slider) #slider value
							calculated_brk.append(Vector2(new_x, new_y))
						
					#make text file
					var brk_file_path = output_file.get_basename() + "_" + str(slider_count) + ".txt"
					write_breakfile(calculated_brk, brk_file_path)
					
					#add breakfile to cleanup before adding flag
					cleanup.append(brk_file_path)
					
					#append text file in place of value
					#include flag if this param has a flag
					if flag.begins_with("-"):
						brk_file_path = flag + brk_file_path
					args.append(brk_file_path)
					
					
				else: #no break file append slider value
					if time == true:
						var soundfile_props = get_soundfile_properties(current_infile[0])
						var infile_length = soundfile_props["duration"]
						if value == 100:
							#if slider is set to 100% default to a millisecond before the end of the file to stop cdp moaning about rounding errors
							value = infile_length - 0.1
						else:
							value = infile_length * (value / 100) #calculate percentage time of the input file
					#line += ("%s%.2f " % [flag, value]) if flag.begins_with("-") else ("%.2f " % value)
					args.append(("%s%.2f " % [flag, value]) if flag.begins_with("-") else str(value))
					
			elif entry[0] == "checkbutton":
				var flag = entry[1]
				var value = entry[2]
				#if button is pressed add the flag to the arguments list
				if value == true:
					args.append(flag)
					
			elif entry[0] == "optionbutton":
				var flag = entry[1]
				var value = entry[2]
				args.append(("%s%.2f " % [flag, value]) if flag.begins_with("-") else str(value))
				
			slider_count += 1
	return [command, output_file, cleanup, args]
	#return [line.strip_edges(), output_file, cleanup]

func remap_y_to_log_scale(y: float, min_y: float, max_y: float, min_val: float, max_val: float) -> float:
	var t = clamp((y - min_y) / (max_y - min_y), 0.0, 1.0)
	# Since y goes top-down (0 = top, 255 = bottom), we invert t
	t = 1.0 - t
	var log_min = log(min_val) / log(10)
	var log_max = log(max_val) / log(10)
	var log_val = lerp(log_min, log_max, t)
	return pow(10.0, log_val)


func sort_points(a, b):
	return a.x < b.x
	
func write_breakfile(points: Array, path: String):
	var file = FileAccess.open(path, FileAccess.WRITE)
	if file:
		for point in points:
			var line = str(point.x) + " " + str(point.y) + "\n"
			file.store_string(line)
		file.close()
	else:
		log_console("Failed to open file to write breakfile", true)

func _on_kill_process_button_down() -> void:
	if process_running and process_info.has("pid"):
		progress_window.hide()
		# Terminate the process by PID
		OS.kill(process_info["pid"])
		process_running = false
		process_cancelled = true

func path_exists_through_all_nodes() -> bool:
	var graph = {}
	var input_node_names = []
	var output_node_name = ""

	# Gather nodes and initialize adjacency list
	for child in graph_edit.get_children():
		if child is GraphNode:
			var name = str(child.name)
			var command = child.get_meta("command")
			var input = child.get_meta("input")

			if input:
				input_node_names.append(name)
			elif command == "outputfile":
				output_node_name = name

			graph[name] = []

	# Add edges
	for conn in graph_edit.get_connection_list():
		var from_node = str(conn["from_node"])
		var to_node = str(conn["to_node"])
		if graph.has(from_node):
			graph[from_node].append(to_node)

	# BFS from each input node
	for input_node in input_node_names:
		var queue = [[input_node]]  # store paths, not just nodes
		while queue.size() > 0:
			var path = queue.pop_front()
			var current = path[-1]

			if current == output_node_name:
				# Candidate path found; validate multi-inlets
				if validate_path_inlets(path, graph, input_node_names):
					return true  # fully valid path found

			for neighbor in graph.get(current, []):
				if neighbor in path:
					continue  # avoid cycles
				var new_path = path.duplicate()
				new_path.append(neighbor)
				queue.append(new_path)

	return false


# Validate all nodes along a candidate path for multi-inlets
func validate_path_inlets(path: Array, graph: Dictionary, input_node_names: Array) -> bool:
	for node_name in path:
		var child = graph_edit.get_node(node_name)
		var input_count = child.get_input_port_count()
		if input_count <= 1:
			continue  # single-inlet nodes are trivially valid

		# Check each inlet
		for i in range(input_count):
			var inlet_valid = false
			for conn in graph_edit.get_connection_list():
				if str(conn["to_node"]) == node_name and conn["to_port"] == i:
					var src_node = str(conn["from_node"])
					if path_has_input(src_node, graph, input_node_names):
						inlet_valid = true
						break
			if not inlet_valid:
				return false  # this inlet cannot reach any input
	return true


# Step backwards from a node to see if a path exists to any input node
func path_has_input(current: String, graph: Dictionary, input_node_names: Array, visited: Dictionary = {}) -> bool:
	if current in input_node_names:
		return true
	if current in visited:
		return false
	visited[current] = true

	# Check all nodes that lead to current
	for conn in graph_edit.get_connection_list():
		if str(conn["to_node"]) == current:
			var src_node = str(conn["from_node"])
			if path_has_input(src_node, graph, input_node_names, visited.duplicate()):
				return true
	return false

#func path_exists_through_all_nodes() -> bool:
	#var graph = {}
	#var input_node_names = []
	#var output_node_name = ""
#
	## Gather nodes and build empty graph
	#for child in graph_edit.get_children():
		#if child is GraphNode:
			#var name = str(child.name)
			#var command = child.get_meta("command")
			#var input = child.get_meta("input")
#
			#if input:
				#input_node_names.append(name)
			#elif command == "outputfile":
				#output_node_name = name
#
			#graph[name] = []  # Initialize adjacency list
#
	## Add connections (edges)
	#for conn in graph_edit.get_connection_list():
		#var from = str(conn["from_node"])
		#var to = str(conn["to_node"])
		#if graph.has(from):
			#graph[from].append(to)
#
	## BFS to check if any input node reaches the output
	#for input_node in input_node_names:
		#var visited = {}
		#var queue = [input_node]
#
		#while queue.size() > 0:
			#var current = queue.pop_front()
#
			#if current == output_node_name:
				#return true  # Path found
#
			#if current in visited:
				#continue
			#visited[current] = true
#
			#for neighbor in graph.get(current, []):
				#queue.append(neighbor)
#
	## No path from any input node to output
	#return false
	
func log_console(text: String, update: bool) -> void:
	console_output.append_text(text + "\n \n")
	console_output.scroll_to_line(console_output.get_line_count() - 1)
	if update == true:
		await get_tree().process_frame  # Optional: ensure UI updates

func run_command(command: String, args: Array) -> String:
	var is_windows = OS.get_name() == "Windows"

	console_output.append_text(command + " " + " ".join(args) + "\n")
	console_output.scroll_to_line(console_output.get_line_count() - 1)
	await get_tree().process_frame
	
	if is_windows and (command == "del" or command == "ren"): #checks if the command is a windows system command and runs it through cmd.exe
		args.insert(0, command)
		args.insert(0, "/C")
		process_info = OS.execute_with_pipe("cmd.exe", args, false)
	else:
		process_info = OS.execute_with_pipe(command, args, false)
	# Check if the process was successfully started
	if !process_info.has("pid"):
		log_console("Failed to start process]", true)
		return ""
	
	process_running = true
	
	# Start monitoring the process output and status
	return await monitor_process(process_info["pid"], process_info["stdio"], process_info["stderr"])

func monitor_process(pid: int, stdout: FileAccess, stderr: FileAccess) -> String:
	var output := ""
	
	while OS.is_process_running(pid):
		await get_tree().process_frame
		
		while stdout.get_position() < stdout.get_length():
			var line = stdout.get_line()
			output += line
			console_output.append_text(line + "\n")
			console_output.scroll_to_line(console_output.get_line_count() - 1)
		while stderr.get_position() < stderr.get_length():
			var line = stderr.get_line()
			output += line
			console_output.append_text(line + "\n")
			console_output.scroll_to_line(console_output.get_line_count() - 1)
	
	var exit_code = OS.get_process_exit_code(pid)
	if exit_code == 0:
		if output.contains("ERROR:"): #checks if CDP reported an error but passed exit code 0 anyway
			console_output.append_text("[color=#9c2828][b]Processes failed[/b][/color]\n\n")
			console_output.scroll_to_line(console_output.get_line_count() - 1)
			process_successful = false
			if process_cancelled == false:
				progress_window.hide()
				if !console_window.visible:
					console_window.popup_centered()
		else:
			console_output.append_text("[color=#638382]Processes ran successfully[/color]\n\n")
			console_output.scroll_to_line(console_output.get_line_count() - 1)
	else:
		console_output.append_text("[color=#9c2828][b]Processes failed with exit code: %d[/b][/color]\n" % exit_code + "\n")
		console_output.scroll_to_line(console_output.get_line_count() - 1)
		process_successful = false
		if process_cancelled == false:
			progress_window.hide()
			if !console_window.visible:
				console_window.popup_centered()
		if output.contains("as an internal or external command"): #check for cdprogs location error on windows
			console_output.append_text("[color=#9c2828][b]Please make sure your cdprogs folder is set to the correct location in the Settings menu. The default location is C:\\CDPR8\\_cdp\\_cdprogs[/b][/color]\n\n")
			console_output.scroll_to_line(console_output.get_line_count() - 1)
		if output.contains("command not found"): #check for cdprogs location error on unix systems
			console_output.append_text("[color=#9c2828][b]Please make sure your cdprogs folder is set to the correct location in the Settings menu. The default location is ~/cdpr8/_cdp/_cdprogs[/b][/color]\n\n")
			console_output.scroll_to_line(console_output.get_line_count() - 1)
			
	process_running = false
	return output

# Main cycle detection
func detect_cycles(graph: Dictionary, loop_nodes: Dictionary) -> bool:
	var visited := {}
	var stack := {}

	for node in graph.keys():
		if _dfs_cycle(node, graph, visited, stack, loop_nodes):
			return true
	return false
	
func _dfs_cycle(node: String, graph: Dictionary, visited: Dictionary, stack: Dictionary, loop_nodes: Dictionary) -> bool:
	if not visited.has(node):
		visited[node] = true
		stack[node] = true

		for neighbor in graph[node]:
			# If neighbor hasn't been visited, recurse
			if not visited.has(neighbor):
				if _dfs_cycle(neighbor, graph, visited, stack, loop_nodes):
					# Cycle found down this path
					if not (loop_nodes.has(node) or loop_nodes.has(neighbor)):
						return true
			elif stack.has(neighbor):
				# Back edge found → cycle
				if not (loop_nodes.has(node) or loop_nodes.has(neighbor)):
					return true

	# Done exploring this node
	stack.erase(node)
	return false
