@tool
extends SceneTree

signal updated(plugin)

const VERSION = "0.2.5"
const DEFAULT_PLUGIN_URL = "https://git::@github.com/%s.git"
const DEFAULT_PLUG_DIR = "res://.plugged"
const DEFAULT_CONFIG_PATH = DEFAULT_PLUG_DIR + "/index.cfg"
const DEFAULT_USER_PLUG_SCRIPT_PATH = "res://plug.gd"
const DEFAULT_BASE_PLUG_SCRIPT_PATH = "res://addons/gd-plug/plug.gd"

const ENV_PRODUCTION = "production"
const ENV_TEST = "test"
const ENV_FORCE = "force"
const ENV_KEEP_IMPORT_FILE = "keep_import_file"
const ENV_KEEP_IMPORT_RESOURCE_FILE = "keep_import_resource_file"

const MSG_PLUG_START_ASSERTION = "_plug_start() must be called first"

var project_dir
var installation_config = ConfigFile.new()
var logger = _Logger.new()

var _installed_plugins
var _plugged_plugins = {}

var _threads = []
var _mutex = Mutex.new()
var _start_time = 0
var threadpool = _ThreadPool.new(logger)


func _init():
	threadpool.connect("all_thread_finished", request_quit)
	project_dir = DirAccess.open("res://")

func _initialize():
	var args = OS.get_cmdline_args()
	# Trim unwanted args passed to godot executable
	for arg in Array(args):
		args.remove_at(0)
		if "plug.gd" in arg:
			break

	var help = false
	var help_config = false
	for arg in args:
		# NOTE: "--key" or "-key" will always be consumed by godot executable, see https://github.com/godotengine/godot/issues/8721
		var key = arg.to_lower()
		match key:
			"help":
				help = true
			"help-config":
				help_config = true
			"detail":
				logger.log_format = _Logger.DEFAULT_LOG_FORMAT_DETAIL
			"debug", "d":
				logger.log_level = _Logger.LogLevel.DEBUG
			"quiet", "q", "silent":
				logger.log_level = _Logger.LogLevel.NONE
			"production":
				OS.set_environment(ENV_PRODUCTION, "true")
			"test":
				OS.set_environment(ENV_TEST, "true")
			"force":
				OS.set_environment(ENV_FORCE, "true")
			"keep-import-file":
				OS.set_environment(ENV_KEEP_IMPORT_FILE, "true")
			"keep-import-resource-file":
				OS.set_environment(ENV_KEEP_IMPORT_RESOURCE_FILE, "true")

	logger.debug("cmdline_args: %s" % args)
	_start_time = Time.get_ticks_msec()
	_plug_start()
	if help_config:
		show_config_syntax()
	elif help or args.size() == 0:
		show_syntax()
	else:
		_plugging()
		match args[0]:
			"init":
				_plug_init()
			"install", "update":
				_plug_install()
			"uninstall":
				_plug_uninstall()
			"clean":
				_plug_clean()
			"upgrade":
				_plug_upgrade()
			"status":
				_plug_status()
			"version":
				logger.info(VERSION)
			_:
				logger.error("Unknown command %s" % args[0])
				show_syntax()
	# NOTE: Do no put anything after this line except request_quit(), as _plug_*() may call request_quit()
	request_quit()

func show_syntax():
	logger.info("gd-plug - Minimal plugin manager for Godot")
	logger.info("")
	logger.info("Usage: godot --headless -s plug.gd action [options...]")
	logger.info("")
	logger.info("Actions:")
	var actions = {
		"init": "Initialize current project by creating plug.gd at root",
		"status": "Check the status of plugins(installed, added or removed), execute this command whenever in doubts",
		"install(alias update)": "Install or update plugins based on plug.gd",
		"uninstall": "Uninstall all plugins, regardless of plug.gd",
		"clean": "Clean unused files/folders from /.plugged",
		"upgrade": "Upgrade addons/gd-plug/plug.gd to the latest version",
		"version": "Print current version of gd-plug",
	}
	logger.indent()
	logger.table_start()
	for action_name in actions:
		logger.table_row([action_name, actions[action_name]])
	logger.table_end()
	logger.dedent()

	logger.info("")
	logger.info("Options:")
	var options = {
		"production": "Install only plugins not marked as dev, or uninstall already installed dev plugins",
		"test": "Testing mode, no files will actually be installed/uninstalled",
		"force": "Force gd-plug to overwrite destination files when running install command. *WARNING: Check README for more details*",
		"keep-import-file": "Keep \".import\" files generated by plugin, when run uninstall command",
		"keep-import-resource-file": "Keep files located in \".import\" that generated by plugin, when run uninstall command",
		"debug(alias d)": "Print debug message",
		"detail": "Print with datetime and log level, \"[{time}] [{level}] {msg}\"",
		"quiet(alias q, silent)": "Disable logging",
		"help": "Show this help",
		"help-config": "plug.gd configuration documentation"
	}
	logger.indent()
	logger.table_start()
	for option_name in options:
		logger.table_row([option_name, options[option_name]])
	logger.table_end()
	logger.dedent()
	logger.info("")

func show_config_syntax():
	logger.info("Configs: plug(src, args={})")
	logger.info("")
	logger.info("Sources:")
	logger.indent()
	logger.info("Github repo: \"username/repo\", for example, \"imjp94/gd-plug\"")
	logger.info("or")
	logger.info("Any valid git url, for example, \"git@github.com:username/repo.git\"")
	logger.dedent()
	logger.info("")
	logger.info("Arguments:")
	var arguments = {
		"include": "Array of strings that define what files or directory to include. Only \"addons/\" will be included if omitted",
		"exclude": "Array of strings that define what files or directory to exclude",
		"branch": "Name of branch to freeze to",
		"tag": "Name of tag to freeze to",
		"commit": "Commit hash string to freeze to, must be full length 40 digits commit-hash, for example, 7a642f90d3fb88976dd913051de994e58e838d1a",
		"dev": "Boolean to mark the plugin as dev or not, plugin marked as dev will not be installed when production command given",
		"on-updated": "Post update hook, a function name declared in plug.gd that will be called whenever the plugin installed/updated"
	}
	logger.indent()
	logger.table_start()
	for argument_name in arguments:
		logger.table_row([argument_name, arguments[argument_name]])
	logger.table_end()
	logger.dedent()
	logger.info("")

func _process(delta):
	threadpool.process(delta)

func _finalize():
	_plug_end()
	threadpool.stop()
	logger.info("Finished, elapsed %.3fs" % ((Time.get_ticks_msec() - _start_time) / 1000.0))

func _on_updated(plugin):
	pass

func _plugging():
	pass

func request_quit(exit_code=-1):
	if threadpool.is_all_thread_finished() and threadpool.is_all_task_finished():
		quit(exit_code)
		return true
	logger.debug("Request quit declined, threadpool is still running")
	return false

# Index installed plugins, or create directory "plugged" if not exists
func _plug_start():
	logger.debug("Plug start")
	if not project_dir.dir_exists(DEFAULT_PLUG_DIR):
		if project_dir.make_dir(ProjectSettings.globalize_path(DEFAULT_PLUG_DIR)) == OK:
			logger.debug("Make dir %s for plugin installation")
	if installation_config.load(DEFAULT_CONFIG_PATH) == OK:
		logger.debug("Installation config loaded")
	else:
		logger.debug("Installation config not found")
	_installed_plugins = installation_config.get_value("plugin", "installed", {})

# Install plugin or uninstall plugin if unlisted
func _plug_end():
	assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
	var test = !OS.get_environment(ENV_TEST).is_empty()
	if not test:
		installation_config.set_value("plugin", "installed", _installed_plugins)
		if installation_config.save(DEFAULT_CONFIG_PATH) == OK:
			logger.debug("Plugged config saved")
		else:
			logger.error("Failed to save plugged config")
	else:
		logger.warn("Skipped saving of plugged config in test mode")
	_installed_plugins = null

func _plug_init():
	assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
	logger.info("Init gd-plug...")
	if FileAccess.file_exists(DEFAULT_USER_PLUG_SCRIPT_PATH):
		logger.warn("%s already exists!" % DEFAULT_USER_PLUG_SCRIPT_PATH)
	else:
		var file = FileAccess.open(DEFAULT_USER_PLUG_SCRIPT_PATH, FileAccess.WRITE)
		file.store_string(INIT_PLUG_SCRIPT)
		file.close()
		logger.info("Created %s" % DEFAULT_USER_PLUG_SCRIPT_PATH)

func _plug_install():
	assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
	threadpool.active = false
	logger.info("Installing...")
	for plugin in _plugged_plugins.values():
		var installed = plugin.name in _installed_plugins
		if installed:
			var installed_plugin = get_installed_plugin(plugin.name)
			if (installed_plugin.dev or plugin.dev) and OS.get_environment(ENV_PRODUCTION):
				logger.info("Remove dev plugin for production: %s" % plugin.name)
				threadpool.enqueue_task(uninstall_plugin.bind(installed_plugin))
			else:
				threadpool.enqueue_task(update_plugin.bind(plugin))
		else:
			threadpool.enqueue_task(install_plugin.bind(plugin))

	var removed_plugins = []
	for plugin in _installed_plugins.values():
		var removed = not (plugin.name in _plugged_plugins)
		if removed:
			removed_plugins.append(plugin)
	if removed_plugins:
		threadpool.disconnect("all_thread_finished", request_quit)
		if not threadpool.is_all_thread_finished():
			threadpool.active = true
			await threadpool.all_thread_finished
			threadpool.active = false
			logger.debug("All installation finished! Ready to uninstall removed plugins...")
		threadpool.connect("all_thread_finished", request_quit)
		for plugin in removed_plugins:
			threadpool.enqueue_task(uninstall_plugin.bind(plugin), Thread.PRIORITY_LOW)
	threadpool.active = true

func _plug_uninstall():
	assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
	threadpool.active = false
	logger.info("Uninstalling...")
	for plugin in _installed_plugins.values():
		var installed_plugin = get_installed_plugin(plugin.name)
		threadpool.enqueue_task(uninstall_plugin.bind(installed_plugin), Thread.PRIORITY_LOW)
	threadpool.active = true

func _plug_clean():
	assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
	threadpool.active = false
	logger.info("Cleaning...")
	var plugged_dir = DirAccess.open(DEFAULT_PLUG_DIR)
	plugged_dir.include_hidden = true
	plugged_dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
	var file = plugged_dir.get_next()
	while not file.is_empty():
		if plugged_dir.current_is_dir():
			if not (file in _installed_plugins):
				logger.info("Remove %s" % file)
				threadpool.enqueue_task(directory_delete_recursively.bind(plugged_dir.get_current_dir() + "/" + file))
		file = plugged_dir.get_next()
	plugged_dir.list_dir_end()
	threadpool.active = true

func _plug_upgrade():
	assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
	threadpool.active = false
	logger.info("Upgrading gd-plug...")
	plug("imjp94/gd-plug")
	var gd_plug = _plugged_plugins["gd-plug"]
	OS.set_environment(ENV_FORCE, "true") # Required to overwrite res://addons/gd-plug/plug.gd
	threadpool.enqueue_task(install_plugin.bind(gd_plug))
	threadpool.disconnect("all_thread_finished", request_quit)
	if not threadpool.is_all_thread_finished():
		threadpool.active = true
		await threadpool.all_thread_finished
		threadpool.active = false
		logger.debug("All installation finished! Ready to uninstall removed plugins...")
	threadpool.connect("all_thread_finished", request_quit)
	threadpool.enqueue_task(directory_delete_recursively.bind(gd_plug.plug_dir))
	threadpool.active = true

func _plug_status():
	assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
	threadpool.active = false
	logger.info("Installed %d plugin%s" % [_installed_plugins.size(), "s" if _installed_plugins.size() > 1 else ""])
	var new_plugins = _plugged_plugins.duplicate()
	var has_checking_plugin = false
	var removed_plugins = []
	for plugin in _installed_plugins.values():
		logger.info("- {name} - {url}".format(plugin))
		new_plugins.erase(plugin.name)
		var removed = not (plugin.name in _plugged_plugins)
		if removed:
			removed_plugins.append(plugin)
		else:
			threadpool.enqueue_task(check_plugin.bind(_plugged_plugins[plugin.name]))
			has_checking_plugin = true
	if has_checking_plugin:
		logger.info("\n", true)
		threadpool.disconnect("all_thread_finished", request_quit)
		threadpool.active = true
		await threadpool.all_thread_finished
		threadpool.active = false
		threadpool.connect("all_thread_finished", request_quit)
		logger.debug("Finished checking plugins, ready to proceed")
	if new_plugins:
		logger.info("\nPlugged %d plugin%s" % [new_plugins.size(), "s" if new_plugins.size() > 1 else ""])
		for plugin in new_plugins.values():
			var is_new = not (plugin.name in _installed_plugins)
			if is_new:
				logger.info("- {name} - {url}".format(plugin))
	if removed_plugins:
		logger.info("\nUnplugged %d plugin%s" % [removed_plugins.size(), "s" if removed_plugins.size() > 1 else ""])
		for plugin in removed_plugins:
			logger.info("- %s removed" % plugin.name)
	var plug_directory = DirAccess.open(DEFAULT_PLUG_DIR)
	var orphan_dirs = []
	if plug_directory.get_open_error() == OK:
		plug_directory.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
		var file = plug_directory.get_next()
		while not file.is_empty():
			if plug_directory.current_is_dir():
				if not (file in _installed_plugins):
					orphan_dirs.append(file)
			file = plug_directory.get_next()
		plug_directory.list_dir_end()
	if orphan_dirs:
		logger.info("\nOrphan directory, %d found in %s, execute \"clean\" command to remove" % [orphan_dirs.size(), DEFAULT_PLUG_DIR])
		for dir in orphan_dirs:
			logger.info("- %s" % dir)
	threadpool.active = true

	if has_checking_plugin:
		request_quit()

# Index & validate plugin
func plug(repo, args={}):
	assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
	repo = repo.strip_edges()
	var plugin_name = get_plugin_name_from_repo(repo)
	if plugin_name in _plugged_plugins:
		logger.info("Plugin already plugged: %s" % plugin_name)
		return
	var plugin = {}
	plugin.name = plugin_name
	plugin.url = ""
	if ":" in repo:
		plugin.url = repo
	elif repo.find("/") == repo.rfind("/"):
		plugin.url = DEFAULT_PLUGIN_URL % repo
	else:
		logger.error("Invalid repo: %s" % repo)
	plugin.plug_dir = DEFAULT_PLUG_DIR + "/" + plugin.name

	var is_valid = true
	plugin.include = args.get("include", [])
	is_valid = is_valid and validate_var_type(plugin, "include", TYPE_ARRAY, "Array")
	plugin.exclude = args.get("exclude", [])
	is_valid = is_valid and validate_var_type(plugin, "exclude", TYPE_ARRAY, "Array")
	plugin.branch = args.get("branch", "")
	is_valid = is_valid and validate_var_type(plugin, "branch", TYPE_STRING, "String")
	plugin.tag = args.get("tag", "")
	is_valid = is_valid and validate_var_type(plugin, "tag", TYPE_STRING, "String")
	plugin.commit = args.get("commit", "")
	is_valid = is_valid and validate_var_type(plugin, "commit", TYPE_STRING, "String")
	if not plugin.commit.is_empty():
		var is_valid_commit = plugin.commit.length() == 40
		if not is_valid_commit:
			logger.error("Expected full length 40 digits commit-hash string, given %s" % plugin.commit)
		is_valid = is_valid and is_valid_commit
	plugin.dev = args.get("dev", false)
	is_valid = is_valid and validate_var_type(plugin, "dev", TYPE_BOOL, "Boolean")
	plugin.on_updated = args.get("on_updated", "")
	is_valid = is_valid and validate_var_type(plugin, "on_updated", TYPE_STRING, "String")
	plugin.install_root = args.get("install_root", "")
	is_valid = is_valid and validate_var_type(plugin, "install_root", TYPE_STRING, "String")

	if is_valid:
		_plugged_plugins[plugin.name] = plugin
		logger.debug("Plug: %s" % plugin)
	else:
		logger.error("Failed to plug %s, validation error" % plugin.name)

func install_plugin(plugin):
	var test = !OS.get_environment(ENV_TEST).is_empty()
	var can_install = OS.get_environment(ENV_PRODUCTION).is_empty() if plugin.dev else true
	if can_install:
		logger.info("Installing plugin %s..." % plugin.name)
		var result = is_plugin_downloaded(plugin)
		if result != OK:
			result = download(plugin)
		else:
			logger.info("Plugin already downloaded")

		if result == OK:
			install(plugin)
		else:
			logger.error("Failed to install plugin %s with error code %d" % [plugin.name, result])

func uninstall_plugin(plugin):
	var test = !OS.get_environment(ENV_TEST).is_empty()
	logger.info("Uninstalling plugin %s..." % plugin.name)
	uninstall(plugin)
	directory_delete_recursively(plugin.plug_dir, {"exclude": [DEFAULT_CONFIG_PATH], "test": test})

func update_plugin(plugin, checking=false):
	if not (plugin.name in _installed_plugins):
		logger.info("%s new plugin" % plugin.name)
		return true

	var git = _GitExecutable.new(ProjectSettings.globalize_path(plugin.plug_dir), logger)
	var installed_plugin = get_installed_plugin(plugin.name)
	var changes = compare_plugins(plugin, installed_plugin)
	var should_clone = false
	var should_pull = false
	var should_reinstall = false

	if plugin.tag or plugin.commit:
		for rev in ["tag", "commit"]:
			var freeze_at = plugin[rev]
			if freeze_at:
				logger.info("%s frozen at %s \"%s\"" % [plugin.name, rev, freeze_at])
				break
	else:
		var ahead_behind = []
		if git.fetch("origin " + plugin.branch if plugin.branch else "origin").exit == OK:
			ahead_behind = git.get_commit_comparison("HEAD", "origin/" + plugin.branch if plugin.branch else "origin")
		var is_commit_behind = !!ahead_behind[1] if ahead_behind.size() == 2 else false
		if is_commit_behind:
			logger.info("%s %d commits behind, update required" % [plugin.name, ahead_behind[1]])
			should_pull = true
		else:
			logger.info("%s up to date" % plugin.name)

	if changes:
		logger.info("%s changed %s" % [plugin.name, changes])
		should_reinstall = true
		if "url" in changes or "branch" in changes or "tag" in changes or "commit" in changes:
			logger.info("%s repository setting changed, update required" % plugin.name)
			should_clone = true

	if not checking:
		if should_clone:
			logger.info("%s cloning from %s..." % [plugin.name, plugin.url])
			var test = !OS.get_environment(ENV_TEST).is_empty()
			uninstall(get_installed_plugin(plugin.name))
			directory_delete_recursively(plugin.plug_dir, {"exclude": [DEFAULT_CONFIG_PATH], "test": test})
			if download(plugin) == OK:
				install(plugin)
		elif should_pull:
			logger.info("%s pulling updates from %s..." % [plugin.name, plugin.url])
			uninstall(get_installed_plugin(plugin.name))
			if git.pull().exit == OK:
				install(plugin)
		elif should_reinstall:
			logger.info("%s reinstalling..." % plugin.name)
			uninstall(get_installed_plugin(plugin.name))
			install(plugin)

func check_plugin(plugin):
	update_plugin(plugin, true)

func download(plugin):
	logger.info("Downloading %s from %s..." % [plugin.name, plugin.url])
	var test = !OS.get_environment(ENV_TEST).is_empty()
	var global_dest_dir = ProjectSettings.globalize_path(plugin.plug_dir)
	if project_dir.dir_exists(plugin.plug_dir):
		directory_delete_recursively(plugin.plug_dir)
	project_dir.make_dir(plugin.plug_dir)
	var result = _GitExecutable.new(global_dest_dir, logger).clone(plugin.url, global_dest_dir, {"branch": plugin.branch, "tag": plugin.tag, "commit": plugin.commit})
	if result.exit == OK:
		logger.info("Successfully download %s" % [plugin.name])
	else:
		logger.info("Failed to download %s" % plugin.name)
		# Make sure plug_dir is clean when failed
		directory_delete_recursively(plugin.plug_dir, {"exclude": [DEFAULT_CONFIG_PATH], "test": test})
	project_dir.remove(plugin.plug_dir) # Remove empty directory
	return result.exit

func install(plugin):
	var include = plugin.get("include", [])
	if include.is_empty(): # Auto include "addons/" folder if not explicitly specified
		include = ["addons/"]
	if OS.get_environment(ENV_FORCE).is_empty() and OS.get_environment(ENV_TEST).is_empty():
		var is_exists = false
		var dest_files = directory_copy_recursively(plugin.plug_dir, "res://" + plugin.install_root, {"include": include, "exclude": plugin.exclude, "test": true, "silent_test": true})
		for dest_file in dest_files:
			if project_dir.file_exists(dest_file):
				logger.warn("%s attempting to overwrite file %s" % [plugin.name, dest_file])
				is_exists = true
		if is_exists:
			logger.warn("Installation of %s terminated to avoid overwriting user files, you may disable safe mode with command \"force\"" % plugin.name)
			return ERR_ALREADY_EXISTS

	logger.info("Installing files for %s..." % plugin.name)
	var test = !OS.get_environment(ENV_TEST).is_empty()
	var dest_files = directory_copy_recursively(plugin.plug_dir, "res://" + plugin.install_root, {"include": include, "exclude": plugin.exclude, "test": test})
	plugin.dest_files = dest_files
	logger.info("Installed %d file%s for %s" % [dest_files.size(), "s" if dest_files.size() > 1 else "", plugin.name])
	if plugin.name != "gd-plug":
		set_installed_plugin(plugin)
	if plugin.on_updated:
		if has_method(plugin.on_updated):
			logger.info("Execute post-update function for %s" % plugin.name)
			_on_updated(plugin)
			call(plugin.on_updated, plugin.duplicate())
			emit_signal("updated", plugin)
	return OK

func uninstall(plugin):
	var test = !OS.get_environment(ENV_TEST).is_empty()
	var keep_import_file = !OS.get_environment(ENV_KEEP_IMPORT_FILE).is_empty()
	var keep_import_resource_file = !OS.get_environment(ENV_KEEP_IMPORT_RESOURCE_FILE).is_empty()
	var dest_files = plugin.get("dest_files", [])
	logger.info("Uninstalling %d file%s for %s..." % [dest_files.size(), "s" if dest_files.size() > 1 else "",plugin.name])
	directory_remove_batch(dest_files, {"test": test, "keep_import_file": keep_import_file, "keep_import_resource_file": keep_import_resource_file})
	logger.info("Uninstalled %d file%s for %s" % [dest_files.size(), "s" if dest_files.size() > 1 else "",plugin.name])
	remove_installed_plugin(plugin.name)

func is_plugin_downloaded(plugin):
	if not project_dir.dir_exists(plugin.plug_dir + "/.git"):
		return

	var git = _GitExecutable.new(ProjectSettings.globalize_path(plugin.plug_dir), logger)
	return git.is_up_to_date(plugin)

# Get installed plugin, thread safe
func get_installed_plugin(plugin_name):
	assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
	_mutex.lock()
	var installed_plugin = _installed_plugins[plugin_name]
	_mutex.unlock()
	return installed_plugin

# Set installed plugin, thread safe
func set_installed_plugin(plugin):
	assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
	_mutex.lock()
	_installed_plugins[plugin.name] = plugin
	_mutex.unlock()

# Remove installed plugin, thread safe
func remove_installed_plugin(plugin_name):
	assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
	_mutex.lock()
	var result = _installed_plugins.erase(plugin_name)
	_mutex.unlock()
	return result

func directory_copy_recursively(from, to, args={}):
	var include = args.get("include", [])
	var exclude = args.get("exclude", [])
	var test = args.get("test", false)
	var silent_test = args.get("silent_test", false)
	var dir = DirAccess.open(from)
	dir.include_hidden = true
	var dest_files = []
	if dir.get_open_error() == OK:
		dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
		var file_name = dir.get_next()
		while not file_name.is_empty():
			var source = dir.get_current_dir() + ("/" if dir.get_current_dir() != "res://" else "") + file_name
			var dest = to + ("/" if to != "res://" else "") + file_name
			
			if dir.current_is_dir():
				dest_files += directory_copy_recursively(source, dest, args)
			else:
				for include_key in include:
					if include_key in source:
						var is_excluded = false
						for exclude_key in exclude:
							if exclude_key in source:
								is_excluded = true
								break
						if not is_excluded:
							if test:
								if not silent_test: logger.warn("[TEST] Writing to %s" % dest)
							else:
								dir.make_dir_recursive(to)
								if dir.copy(source, dest) == OK:
									logger.debug("Copy from %s to %s" % [source, dest])
							dest_files.append(dest)
						break
			file_name = dir.get_next()
		dir.list_dir_end()
	else:
		logger.error("Failed to access path: %s" % from)
	
	return dest_files

func directory_delete_recursively(dir_path, args={}):
	var remove_empty_directory = args.get("remove_empty_directory", true)
	var exclude = args.get("exclude", [])
	var test = args.get("test", false)
	var silent_test = args.get("silent_test", false)
	var dir = DirAccess.open(dir_path)
	dir.include_hidden = true
	if dir.get_open_error() == OK:
		dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
		var file_name = dir.get_next()
		while not file_name.is_empty():
			var source = dir.get_current_dir() + ("/" if dir.get_current_dir() != "res://" else "") + file_name
			
			if dir.current_is_dir():
				var sub_dir = directory_delete_recursively(source, args)
				if remove_empty_directory:
					if test:
						if not silent_test: logger.warn("[TEST] Remove empty directory: %s" % sub_dir.get_current_dir())
					else:
						if source.get_file() == ".git":
							var empty_dir_path = ProjectSettings.globalize_path(source)
							var exit = FAILED
							match OS.get_name():
								"Windows":
									empty_dir_path = "\"%s\"" % empty_dir_path
									empty_dir_path = empty_dir_path.replace("/", "\\")
									var cmd = "rd /s /q %s" % empty_dir_path
									exit = OS.execute("cmd", ["/C", cmd])
								"X11", "OSX", "Server":
									empty_dir_path = "\'%s\'" % empty_dir_path
									var cmd = "rm -rf %s" % empty_dir_path
									exit = OS.execute("bash", ["-c", cmd])
							# Hacks to remove .git, as git pack files stop it from being removed
							# See https://stackoverflow.com/questions/1213430/how-to-fully-delete-a-git-repository-created-with-init
							if exit == OK:
								logger.debug("Remove empty directory: %s" % sub_dir.get_current_dir())
							else:
								logger.debug("Failed to remove empty directory: %s" % sub_dir.get_current_dir())
						else:
							if dir.remove(sub_dir.get_current_dir()) == OK:
								logger.debug("Remove empty directory: %s" % sub_dir.get_current_dir())
			else:
				var excluded = false
				for exclude_key in exclude:
					if source in exclude_key:
						excluded = true
						break
				if not excluded:
					if test:
						if not silent_test: logger.warn("[TEST] Remove file: %s" % source)
					else:
						if dir.remove(file_name) == OK:
							logger.debug("Remove file: %s" % source)
			file_name = dir.get_next()
		dir.list_dir_end()
	else:
		logger.error("Failed to access path: %s" % dir_path)

	if remove_empty_directory:
		dir.remove(dir.get_current_dir())

	return dir

func directory_remove_batch(files, args={}):
	var remove_empty_directory = args.get("remove_empty_directory", true)
	var keep_import_file = args.get("keep_import_file", false)
	var keep_import_resource_file = args.get("keep_import_resource_file", false)
	var test = args.get("test", false)
	var silent_test = args.get("silent_test", false)
	var dirs = {}
	for file in files:
		var file_dir = file.get_base_dir()
		var file_name =file.get_file()
		var dir = dirs.get(file_dir)
		
		if not dir:
			dir = DirAccess.open(file_dir)
			dirs[file_dir] = dir

		if file.ends_with(".import"):
			if not keep_import_file:
				_remove_import_file(dir, file, keep_import_resource_file, test, silent_test)
		else:
			if test:
				if not silent_test: logger.warn("[TEST] Remove file: " + file)
			else:
				if dir.remove(file_name) == OK:
					logger.debug("Remove file: " + file)
			if not keep_import_file:
				_remove_import_file(dir, file + ".import", keep_import_resource_file, test, silent_test)
		
	for dir in dirs.values():
		var slash_count = dir.get_current_dir().count("/") - 2 # Deduct 2 slash from "res://"
		if test:
			if not silent_test: logger.warn("[TEST] Remove empty directory: %s" % dir.get_current_dir())
		else:
			if dir.remove(dir.get_current_dir()) == OK:
				logger.debug("Remove empty directory: %s" % dir.get_current_dir())
		# Dumb method to clean empty ancestor directories
		logger.debug("Removing emoty ancestor directory for %s..." % dir.get_current_dir())
		var current_dir = dir.get_current_dir()
		for i in slash_count:
			current_dir = current_dir.get_base_dir()
			var d = DirAccess.open(current_dir)
			if d.get_open_error() == OK:
				if test:
					if not silent_test: logger.warn("[TEST] Remove empty ancestor directory: %s" % d.get_current_dir())
				else:
					if d.remove(d.get_current_dir()) == OK:
						logger.debug("Remove empty ancestor directory: %s" % d.get_current_dir())

func _remove_import_file(dir, file, keep_import_resource_file=false, test=false, silent_test=false):
	if not dir.file_exists(file):
		return

	if not keep_import_resource_file:
		var import_config = ConfigFile.new()
		if import_config.load(file) == OK:
			var metadata = import_config.get_value("remap", "metadata", {})
			var imported_formats = metadata.get("imported_formats", [])
			if imported_formats:
				for format in imported_formats:
					_remove_import_resource_file(dir, import_config, "." + format, test)
			else:
				_remove_import_resource_file(dir, import_config, "", test)
	if test:
		if not silent_test: logger.warn("[TEST] Remove import file: " + file)
	else:
		if dir.remove(file) == OK:
			logger.debug("Remove import file: " + file)
		else:
			# TODO: Sometimes Directory.remove() unable to remove random .import file and return error code 1(Generic Error)
			# Maybe enforce the removal from shell?
			logger.warn("Failed to remove import file: " + file)

func _remove_import_resource_file(dir, import_config, import_format="", test=false):
	var import_resource_file = import_config.get_value("remap", "path" + import_format, "")
	var checksum_file = import_resource_file.trim_suffix("." + import_resource_file.get_extension()) + ".md5" if import_resource_file else ""
	if import_resource_file:
		if dir.file_exists(import_resource_file):
			if test:
				logger.info("[IMPORT] Remove import resource file: " + import_resource_file)
			else:
				if dir.remove(import_resource_file) == OK:
					logger.debug("Remove import resource file: " + import_resource_file)
	if checksum_file:
		checksum_file = checksum_file.replace(import_format, "")
		if dir.file_exists(checksum_file):
			if test:
				logger.info("[IMPORT] Remove import checksum file: " + checksum_file)
			else:
				if dir.remove(checksum_file) == OK:
					logger.debug("Remove import checksum file: " + checksum_file)

func compare_plugins(p1, p2):
	var changed_keys = []
	for key in p1.keys():
		var v1 = p1[key]
		var v2 = p2[key]
		if v1 != v2:
			changed_keys.append(key)
	return changed_keys

func get_plugin_name_from_repo(repo):
	repo = repo.replace(".git", "").trim_suffix("/")
	return repo.get_file()

func validate_var_type(obj, var_name, type, type_string):
	var value = obj.get(var_name)
	var is_valid = typeof(value) == type
	if not is_valid:
		logger.error("Expected variable \"%s\" to be %s, given %s" % [var_name, type_string, value])
	return is_valid

const INIT_PLUG_SCRIPT = \
"""extends "res://addons/gd-plug/plug.gd"

func _plugging():
	# Declare plugins with plug(repo, args)
	# For example, clone from github repo("user/repo_name")
	# plug("imjp94/gd-YAFSM") # By default, gd-plug will only install anything from "addons/" directory
	# Or you can explicitly specify which file/directory to include
	# plug("imjp94/gd-YAFSM", {"include": ["addons/"]}) # By default, gd-plug will only install anything from "addons/" directory
	pass
"""

class _GitExecutable extends RefCounted:
	var cwd = ""
	var logger

	func _init(p_cwd, p_logger):
		cwd = p_cwd
		logger = p_logger

	func _execute(command, output=[], read_stderr=false):
		var cmd = "cd '%s' && %s" % [cwd, command]
		# NOTE: OS.execute() seems to ignore read_stderr
		var exit = FAILED
		match OS.get_name():
			"Windows":
				cmd = cmd.replace("\'", "\"") # cmd doesn't accept single-quotes
				cmd = cmd if read_stderr else "%s 2> nul" % cmd
				logger.debug("Execute \"%s\"" % cmd)
				exit = OS.execute("cmd", ["/C", cmd], output, read_stderr)
			"macOS", "Linux", "FreeBSD", "NetBSD", "OpenBSD", "BSD":
				cmd if read_stderr else "%s 2>/dev/null" % cmd
				logger.debug("Execute \"%s\"" % cmd)
				exit = OS.execute("bash", ["-c", cmd], output, read_stderr)
			var unhandled_os:
				logger.error("Unexpected OS: %s" % unhandled_os)
		logger.debug("Execution ended(code:%d): %s" % [exit, output])
		return exit

	func init():
		logger.debug("Initializing git at %s..." % cwd)
		var output = []
		var exit = _execute("git init", output)
		logger.debug("Successfully init" if exit == OK else "Failed to init")
		return {"exit": exit, "output": output}

	func clone(src, dest, args={}):
		logger.debug("Cloning from %s to %s..." % [src, dest])
		var output = []
		var branch = args.get("branch", "")
		var tag = args.get("tag", "")
		var commit = args.get("commit", "")
		var command = "git clone --depth=1 --progress '%s' '%s'" % [src, dest]
		if branch or tag:
			command = "git clone --depth=1 --single-branch --branch %s '%s' '%s'" % [branch if branch else tag, src, dest]
		elif commit:
			return clone_commit(src, dest, commit)
		var exit = _execute(command, output)
		logger.debug("Successfully cloned from %s" % src if exit == OK else "Failed to clone from %s" % src)
		return {"exit": exit, "output": output}

	func clone_commit(src, dest, commit):
		var output = []
		if commit.length() < 40:
			logger.error("Expected full length 40 digits commit-hash to clone specific commit, given {%s}" % commit)
			return {"exit": FAILED, "output": output}

		logger.debug("Cloning from %s to %s @ %s..." % [src, dest, commit])
		var result = init()
		if result.exit == OK:
			result = remote_add("origin", src)
			if result.exit == OK:
				result = fetch("%s %s" % ["origin", commit])
				if result.exit == OK:
					result = reset("--hard", "FETCH_HEAD")
		return result

	func fetch(rm="--all"):
		logger.debug("Fetching %s..." % rm.replace("--", ""))
		var output = []
		var exit = _execute("git fetch %s" % rm, output)
		logger.debug("Successfully fetched" if exit == OK else "Failed to fetch")
		return {"exit": exit, "output": output}

	func pull():
		logger.debug("Pulling...")
		var output = []
		var exit = _execute("git pull --rebase", output)
		logger.debug("Successfully pulled" if exit == OK else "Failed to pull")
		return {"exit": exit, "output": output}

	func remote_add(name, src):
		logger.debug("Adding remote %s@%s..." % [name, src])
		var output = []
		var exit = _execute("git remote add %s '%s'" % [name, src], output)
		logger.debug("Successfully added remote" if exit == OK else "Failed to add remote")
		return {"exit": exit, "output": output}

	func reset(mode, to):
		logger.debug("Resetting %s %s..." % [mode, to])
		var output = []
		var exit = _execute("git reset %s %s" % [mode, to], output)
		logger.debug("Successfully reset" if exit == OK else "Failed to reset")
		return {"exit": exit, "output": output}

	func get_commit_comparison(branch_a, branch_b):
		var output = []
		var exit = _execute("git rev-list --count --left-right %s...%s" % [branch_a, branch_b], output)
		var raw_ahead_behind = output[0].split("\t")
		var ahead_behind = []
		for msg in raw_ahead_behind:
			ahead_behind.append(msg.to_int())
		return ahead_behind if exit == OK else []

	func get_current_branch():
		var output = []
		var exit = _execute("git rev-parse --abbrev-ref HEAD", output)
		return output[0] if exit == OK else ""

	func get_current_tag():
		var output = []
		var exit = _execute("git describe --tags --exact-match", output)
		return output[0] if exit == OK else ""

	func get_current_commit():
		var output = []
		var exit = _execute("git rev-parse --short HEAD", output)
		return output[0] if exit == OK else ""

	func is_detached_head():
		var output = []
		var exit = _execute("git rev-parse --short HEAD", output)
		return (!!output[0]) if exit == OK else true

	func is_up_to_date(args={}):
		if fetch().exit == OK:
			var branch = args.get("branch", "")
			var tag = args.get("tag", "")
			var commit = args.get("commit", "")
	
			if branch:
				if branch == get_current_branch():
					return FAILED if is_detached_head() else OK
			elif tag:
				if tag == get_current_tag():
					return OK
			elif commit:
				if commit == get_current_commit():
					return OK
	
			var ahead_behind = get_commit_comparison("HEAD", "origin")
			var is_commit_behind = !!ahead_behind[1] if ahead_behind.size() == 2 else false
			return FAILED if is_commit_behind else OK
		return FAILED

class _ThreadPool extends RefCounted:
	signal all_thread_finished()

	var active = true

	var _threads = []
	var _finished_threads = []
	var _mutex = Mutex.new()
	var _tasks = []
	var logger

	func _init(p_logger):
		logger = p_logger
		_threads.resize(OS.get_processor_count())

	func _execute_task(task):
		var thread = _get_thread()
		var can_execute = thread
		if can_execute:
			task.thread = weakref(thread)
			var callable = task.get("callable")
			thread.start(_execute.bind(task), task.priority)
			logger.debug("Execute task %s.%s() " % [callable.get_object(), callable.get_method()])
		return can_execute

	func _execute(args):
		var callable = args.get("callable")
		callable.call()
		_mutex.lock()
		var thread = args.thread.get_ref()
		_threads[_threads.find(thread)] = null
		_finished_threads.append(thread)
		var all_finished = is_all_thread_finished()
		_mutex.unlock()

		logger.debug("Execution finished %s.%s() " % [callable.get_object(), callable.get_method()])
		if all_finished:
			logger.debug("All thread finished")
			emit_signal("all_thread_finished")

	func _flush_tasks():
		if _tasks.size() == 0:
			return

		var executed = true
		while executed:
			var task = _tasks.pop_front()
			if task != null:
				executed = _execute_task(task)
				if not executed:
					_tasks.push_front(task)
			else:
				executed = false

	func _flush_threads():
		for i in _finished_threads.size():
			var thread = _finished_threads.pop_front()
			if not thread.is_alive():
				thread.wait_to_finish()

	func enqueue_task(callable, priority=1):
		enqueue({"callable": callable, "priority": priority})

	func enqueue(task):
		var can_execute = false
		if active:
			can_execute = _execute_task(task)
		if not can_execute:
			_tasks.append(task)

	func process(delta):
		if active:
			_flush_tasks()
			_flush_threads()

	func stop():
		_tasks.clear()
		_flush_threads()

	func _get_thread():
		var thread
		for i in OS.get_processor_count():
			var t = _threads[i]
			if t:
				if not t.is_started():
					thread = t
					break
			else:
				thread = Thread.new()
				_threads[i] = thread
				break
		return thread

	func is_all_thread_finished():
		for i in _threads.size():
			if _threads[i]:
				return false
		return true

	func is_all_task_finished():
		for i in _tasks.size():
			if _tasks[i]:
				return false
		return true

class _Logger extends RefCounted:
	enum LogLevel {
		ALL, DEBUG, INFO, WARN, ERROR, NONE
	}
	const DEFAULT_LOG_FORMAT_DETAIL = "[{time}] [{level}] {msg}"
	const DEFAULT_LOG_FORMAT_NORMAL = "{msg}"
	
	var log_level = LogLevel.INFO
	var log_format = DEFAULT_LOG_FORMAT_NORMAL
	var log_time_format = "{year}/{month}/{day} {hour}:{minute}:{second}"
	var indent_level = 0
	var is_locked = false

	var _rows
	var _max_column_length = []
	var _max_column_size = 0

	func debug(msg, raw=false):
		_log(LogLevel.DEBUG, msg, raw)

	func info(msg, raw=false):
		_log(LogLevel.INFO, msg, raw)

	func warn(msg, raw=false):
		_log(LogLevel.WARN, msg, raw)

	func error(msg, raw=false):
		_log(LogLevel.ERROR, msg, raw)

	func _log(level, msg, raw=false):
		if is_locked:
			return
		
		if typeof(msg) != TYPE_STRING:
			msg = str(msg)
		if log_level <= level:
			match level:
				LogLevel.WARN:
					push_warning(format_log(level, msg))
				LogLevel.ERROR:
					push_error(format_log(level, msg))
				_:
					if raw:
						printraw(format_log(level, msg))
					else:
						print(format_log(level, msg))

	func format_log(level, msg):
		return log_format.format({
			"time": log_time_format.format(get_formatted_datatime()),
			"level": LogLevel.keys()[level],
			"msg": msg.indent("    ".repeat(indent_level))
		})

	func indent():
		indent_level += 1
	
	func dedent():
		indent_level -= 1
		max(indent_level, 0)
	
	func lock():
		is_locked = true
	
	func unlock():
		is_locked = false

	func table_start():
		_rows = []

	func table_end():
		assert(_rows != null, "Expected table_start() to be called first")
		for columns in _rows:
			var text = ""
			for i in columns.size():
				var column = columns[i]
				var max_tab_count = ceil(float(_max_column_length[i]) / 4.0)
				var tab_count = max_tab_count - ceil(float(column.length()) / 4.0)
				var extra_spaces = ceil(float(column.length()) / 4.0) * 4 - column.length()
				if i < _max_column_size - 1:
					text += column + " ".repeat(extra_spaces) + "    ".repeat(tab_count)
				else:
					text += column
			info(text)
		
		_rows.clear()
		_rows = null
		_max_column_length.clear()
		_max_column_size = 0
	
	func table_row(columns=[]):
		assert(_rows != null, "Expected table_start() to be called first")
		_rows.append(columns)
		_max_column_size = max(_max_column_size, columns.size())
		for i in columns.size():
			var column = columns[i]
			if _max_column_length.size() >= i + 1:
				var max_column_length = _max_column_length[i]
				_max_column_length[i] = max(max_column_length, column.length())
			else:
				_max_column_length.append(column.length())
	
	func get_formatted_datatime():
		var datetime = Time.get_datetime_dict_from_system()
		datetime.year = "%04d" % datetime.year
		datetime.month = "%02d" % datetime.month
		datetime.day = "%02d" % datetime.day
		datetime.hour = "%02d" % datetime.hour
		datetime.minute = "%02d" % datetime.minute
		datetime.second = "%02d" % datetime.second
		return datetime
