@tool
extends Object
class_name Vest

## Utility singleton for running tests.
##
## @tutorial(Printing custom messages): https://foxssake.github.io/vest/latest/user-guide/printing-custom-messages/

const Icons := preload("res://addons/vest/icons/vest-icons.gd")
const Timeout := preload("res://addons/vest/timeout.gd")

const ValueMeasure := preload("res://addons/vest/measures/value-measure.gd")
const SumMeasure := preload("res://addons/vest/measures/sum-measure.gd")
const AverageMeasure := preload("res://addons/vest/measures/average-measure.gd")
const MinMeasure := preload("res://addons/vest/measures/min-measure.gd")
const MaxMeasure := preload("res://addons/vest/measures/max-measure.gd")

const __ := preload("res://addons/vest/vest-internals.gd")

const NEW_TEST_MIRROR_DIR_STRUCTURE := 0
const NEW_TEST_NEXT_TO_SOURCE := 1
const NEW_TEST_IN_ROOT := 2

static var _messages: Array[String] = []
static var _scene_tree: SceneTree
static var _editor_interface_provider: Callable

## Add a custom message to the current test.
## [br][br]
## Will be included in the test report.
static func message(p_message: String):
	_messages.append(p_message)

## Wait for [param condition] to be true, or until timeout.
## [br][br]
## This method will start a loop, each time waiting [param interval] seconds.
## On each iteration, [param condition] is called. If its result is true, or the
## [param duration] has been exceeded, the loop stops.
## [br][br]
## If [param duration] is 0., the loop may run infinitely, without timeout.
## [br][br]
## Returns [constant OK] if [param condition] was true.[br]
## Returns [constant ERR_TIMEOUT] if [param duration] was exceeded.[br]
## Returns [constant ERR_UNAVAILABLE] if no [SceneTree] is available.
static func until(condition: Callable, duration: float = 5., interval: float = 0.0) -> Error:
	var deadline := time() + duration

	if not _scene_tree:
		push_warning("Missing reference to SceneTree, will return immediately!")
		return ERR_UNAVAILABLE

	while time() < deadline:
		if condition.call():
			return OK

		if is_zero_approx(duration): await _scene_tree.process_frame
		else: await _scene_tree.create_timer(interval).timeout

	return ERR_TIMEOUT

## Create a Timeout. Timeouts can be reused for multiple wait operations. This
## is useful when a group of sequential tasks have a single shared deadline.
## [br][br]
## See [method until] for further info on the parameters.
static func timeout(duration: float = 5.0, interval: float = 0.0) -> Timeout:
	return Timeout.new(duration, interval, _scene_tree)

## Wait for [param duration] seconds.
## [br][br]
## Waiting for 0 seconds will wait until the next
## [signal SceneTree.process_frame].
## [br][br]
## Returns [constant OK] on success.[br]
## Returns [constant ERR_UNAVAILABLE] if no [SceneTree] is available.
static func sleep(duration: float = 0.) -> Error:
	if not _scene_tree:
		push_warning("Missing reference to SceneTree, will return immediately!")
		return ERR_UNAVAILABLE

	if is_zero_approx(duration): await _scene_tree.process_frame
	else: await _scene_tree.create_timer(duration).timeout
	return OK

## Get the test runner's timeout in seconds.
##
## If the test runner doesn't finish running the tests in this time, it's
## considered stuck. This value is read from the project settings.
## [br][br]
## For more info, see the
## [url=https://foxssake.github.io/vest/latest/user-guide/project-settings/#runner-timeout]user
## guide[/url].
static func get_runner_timeout() -> float:
	return ProjectSettings.get_setting("vest/runner_timeout", 8.0)

## Get the sources root for this project.
##
## This is directory is assumed root for all the implementations, and is used
## for deriving paths to test suites. This value is read from the project
## settings.
## [br][br]
## For more info, see the
## [url=https://foxssake.github.io/vest/latest/user-guide/project-settings/#sources-root]user
## guide[/url].
static func get_sources_root() -> String:
	return ProjectSettings.get_setting("vest/sources_root", "res://")

## Get the test sources root for this project.
##
## This is directory is assumed root for all the test suites, and is used
## for deriving paths. This value is read from the project settings.
## [br][br]
## For more info, see the
## [url=https://foxssake.github.io/vest/latest/user-guide/project-settings/#tests-root]user
## guide[/url].
static func get_tests_root() -> String:
	return ProjectSettings.get_setting("vest/tests_root", "res://tests/")

## Get the list of configured test name patterns.
##
## These are used to recognize and generate filenames for test suites. This
## value is read from the project settings.
## [br][br]
## For more info, see the
## [url=https://foxssake.github.io/vest/latest/user-guide/project-settings/#test-name-patterns]user
## guide[/url].
static func get_test_name_patterns() -> Array[FilenamePattern]:
	# TODO: Memoize
	var pattern_strings := ProjectSettings.get_setting("vest/test_name_patterns") as PackedStringArray
	var patterns := [] as Array[FilenamePattern]
	patterns.assign(Array(pattern_strings).map(func(it): return FilenamePattern.new(it)))

	return patterns

## Get the preference for new test location.
##
## This setting determines what path to suggest when creating new tests.
## [br][br]
## For more info, see the
## [url=https://foxssake.github.io/vest/latest/user-guide/project-settings/#new-test-location]
## user guide[/url].
static func get_new_test_location_preference() -> int:
	return ProjectSettings.get_setting("vest/new_test_location", NEW_TEST_MIRROR_DIR_STRUCTURE)

## Get the current time, in seconds.
## [br][br]
## Used for benchmarking and waiting [method until] a condition becomes true.
static func time() -> float:
	return Time.get_unix_time_from_system()

## Traverse [param directory] recursively and call [param visitor] on each file.
static func traverse_directory(directory: String, visitor: Callable, max_iters: int = 131072) -> void:
	var da := DirAccess.open(directory)
	da.include_navigational = false

	var dir_queue := [directory]
	var dir_history := []

	# Put an upper limit on iterations, so we can't run into a runaway loop
	for i in max_iters:
		if dir_queue.is_empty():
			break

		var dir_at := dir_queue.pop_front() as String
		da.change_dir(dir_at)
		dir_history.append(dir_at)

		# Add directories to queue
		for dir_name in da.get_directories():
			var dir := da.get_current_dir().path_join(dir_name)
			if not dir_history.has(dir) and not dir_queue.has(dir):
				dir_queue.append(dir)

		# Visit files
		for file_name in da.get_files():
			var file := da.get_current_dir().path_join(file_name)
			visitor.call(file)

## Return all paths that match the glob [param pattern].
## The matching is done using [method String.match].
static func glob(pattern: String, max_iters: int = 131072) -> Array[String]:
	if pattern.is_empty(): return []
	var results: Array[String] = []

	var first_glob_index := mini(
		(pattern.find("?") + pattern.length()) % pattern.length(),
		(pattern.find("*") + pattern.length()) % pattern.length()
	)
	var root := (
		pattern.get_base_dir()
		if first_glob_index < 0 else
		pattern.substr(0, first_glob_index).get_base_dir()
	)

	traverse_directory(root, func(path: String):
		if path.match(pattern):
			results.append(path)
	, max_iters)

	return results

## Returns a [SceneTree]. May be used for tests requiring more complex node
## setups.
static func get_tree() -> SceneTree:
	return _scene_tree

static func _clear_messages():
	_messages.clear()

static func _get_messages() -> Array[String]:
	return _messages.duplicate()

static func _register_scene_tree(scene_tree: SceneTree):
	_scene_tree = scene_tree

# HACK: Godot script compiler fails in 4.2+ if the return type is an engine singleton
# The best we can do is return an object and assume the methods exist.
# Casting isn't possible either, also freaks out the script compiler.
static func _get_editor_interface() -> Object:
	if Engine.get_version_info().hex >= 0x040200:
		return Engine.get_singleton("EditorInterface")
	elif _editor_interface_provider.is_valid():
		return _editor_interface_provider.call()
	else:
		return null

static func _get_editor_scale() -> float:
	var interface := _get_editor_interface()
	if interface and interface.has_method("get_editor_scale"):
		return interface.get_editor_scale()
	else:
		return 1.0

static func _register_editor_interface_provider(provider: Callable):
	_editor_interface_provider = provider
