@tool

## Loggie is a basic logging utility for those who need common minor improvements and helpers around the basic [method print], [method print_rich]
## and other default Godot printing functions. Loggie creates instances of [LoggieMsg], which are a wrapper around a string that needs to manipulated,
## then uses them to properly format, arrange and present them in the console and .log files. Loggie uses the default Godot logging mechanism under the hood.
extends Node

## The current version of Loggie.
## Needs to be updated manually when changing the version.
var version : LoggieVersion = LoggieVersion.new(3,0)

## Emitted any time Loggie attempts to log a message.
## Useful for capturing the messages that pass through Loggie.
## [br][param msg] is the message Loggie attempted to log (before any preprocessing).
## [br][param msg_string] is a shortcut providing the string content of that message.
## [br][param result] describes the final result of the attempt to log that message.
signal log_attempted(msg : LoggieMsg, msg_string : String, result : LoggieEnums.LogAttemptResult)

## A reference to the settings of this Loggie. Read more about [LoggieSettings].
var settings : LoggieSettings

## Holds a mapping between all registered domains (string keys) and bool values representing whether
## those domains are currently enabled. Enable domains with [method set_domain_enabled].
## You can then place [LoggieMsg] messages into a domain by calling [method LoggieMsg.domain].
## Messages belonging to a disabled domain will never be outputted.
var domains : Dictionary = {}

## Holds a mapping between script paths and the names of the classes defined in those scripts.
var class_names : Dictionary = {}

## Holds a mapping between channel IDs (string) and the
## [LoggieMsgChannel] objects those IDs are representing.
var available_channels = {}

## Stores a reference to a [LoggieVersionManager] that will be used to manage the
## version of this instance.
var version_manager : LoggieVersionManager = LoggieVersionManager.new()

## Stores a reference to each [LoggiePreset] that was created.
## The key is the ID (string) of the preset, and the value is the [LoggieMsg] that was saved as a preset.
var presets : Dictionary = {}

func _init() -> void:
	# Connect the version manager to this logger.
	version_manager.connect_logger(self)

	# Load and initialize the settings.
	var uses_original_settings_file = true
	var default_settings_path = get_script().get_path().get_base_dir().path_join("loggie_settings.gd")
	var custom_settings_path = get_script().get_path().get_base_dir().path_join("custom_settings.gd")
	
	if self.settings == null:
		if custom_settings_path != null and custom_settings_path != "" and ResourceLoader.exists(custom_settings_path):
			var loaded_successfully = load_settings_from_path(custom_settings_path)
			if loaded_successfully:
				uses_original_settings_file = false

	if uses_original_settings_file:
		var _settings = ResourceLoader.load(default_settings_path)
		if _settings != null:
			self.settings = _settings.new()
			self.settings.load()
		else:
			push_error("Loggie loaded neither a custom nor a default settings file. This will break the plugin. Make sure that a valid loggie_settings.gd is in the same directory where loggie.gd is.")
			return

	# Enforce certain settings if configured to do so.
	if self.settings.enforce_optimal_settings_in_release_build == true and is_in_production():
		apply_production_optimal_settings()

	# Set the default custom string converter.
	self.settings.custom_string_converter = LoggieTools.convert_to_string

	# Install all the built-in channels.
	var terminal_channel : TerminalLoggieMsgChannel = load("res://addons/loggie/channels/terminal.gd").new()
	terminal_channel.preprocess_flags = self.settings.preprocess_flags_terminal_channel
	add_channel(terminal_channel)
	var discord_channel : DiscordLoggieMsgChannel = load("res://addons/loggie/channels/discord.gd").new()
	discord_channel.preprocess_flags = self.settings.preprocess_flags_discord_channel
	add_channel(discord_channel)
	var slack_channel : SlackLoggieMsgChannel = load("res://addons/loggie/channels/slack.gd").new()
	slack_channel.preprocess_flags = self.settings.preprocess_flags_slack_channel
	add_channel(slack_channel)

	# Already cache the name of the singleton found at loggie's script path.
	class_names[self.get_script().resource_path] = LoggieSettings.loggie_singleton_name

	# Prepopulate class data from ProjectSettings to avoid needing to read files.
	if OS.has_feature("debug"):
		for class_data: Dictionary in ProjectSettings.get_global_class_list():
			class_names[class_data.path] = class_data.class
	  
		for autoload_setting: String in ProjectSettings.get_property_list().map(func(prop): return prop.name).filter(func(prop): return prop.begins_with("autoload/") and ProjectSettings.has_setting(prop)):
			var autoload_class: String = autoload_setting.trim_prefix("autoload/")
			var class_path: String = ProjectSettings.get_setting(autoload_setting)
			class_path = class_path.trim_prefix("*")      
			if not class_names.has(class_path):
				class_names[class_path] = autoload_class

	# And don't proceed further if we're in Editor mode, since we don't need to show loggie boot messages in that case.
	if Engine.is_editor_hint():
		return 
	
	# Print the Loggie boot messages.
	if self.settings.show_loggie_specs != LoggieEnums.ShowLoggieSpecsMode.DISABLED:
		msg("👀 Loggie {version}{isproxy} booted.".format({
			"version" : self.version_manager.version,
			"isproxy" : " (proxy for {original})".format({"original": self.version_manager.version.proxy_for}) if self.version_manager.version.proxy_for != null else ""
		})).color(Color.ORANGE).header().nl().info()
		var loggie_specs_msg = LoggieSystemSpecsMsg.new().use_logger(self)
		loggie_specs_msg.add(msg("|\t Using Custom Settings File: ").bold(), !uses_original_settings_file).nl().add("|\t ").hseparator(35).nl()
		
		match self.settings.show_loggie_specs:
			LoggieEnums.ShowLoggieSpecsMode.ESSENTIAL:
				loggie_specs_msg.embed_essential_logger_specs()
			LoggieEnums.ShowLoggieSpecsMode.ADVANCED:
				loggie_specs_msg.embed_advanced_logger_specs()

		loggie_specs_msg.preprocessed(false).info()

	if self.settings.show_system_specs:
		var system_specs_msg = LoggieSystemSpecsMsg.new().use_logger(self)
		system_specs_msg.embed_specs().preprocessed(false).info()

## Attempts to instantiate and use a LoggieSettings object from the script at the given [param path].
## Returns true if successful, otherwise false and prints an error.
func load_settings_from_path(path : String) -> bool:
	var settings_resource = ResourceLoader.load(path)
	var settings_instance

	if settings_resource != null:
		settings_instance = settings_resource.new()

	if (settings_instance is LoggieSettings):
		self.settings = settings_instance
		self.settings.load()
		return true
	else:
		push_error("Unable to instantiate a LoggieSettings object from the script at path {path}. Check that loggie.gd -> custom_settings_path is pointing to a valid .gd script that contains the class definition of a class that either extends LoggieSettings, or is LoggieSettings.".format({"path": path}))
		return false

## Checks if Loggie is running in production (release) mode of the game.
## While it is, every [LoggieMsg] will have plain output.
## Uses a sensible default check for most projects, but
## you can rewrite this function to your needs if necessary.
## TODO: Port this out of Loggie into LoggieSettings so users can override it easier.
func is_in_production() -> bool:
	return OS.has_feature("release")

## Returns a custom list of channels that messages from the given [param domain_name] will be sent to.
## This list can be set through the [method set_domain_enabled] method.
## If the list is empty, default channels will be used.
func get_domain_custom_target_channels(domain_name : String) -> Array:
	if domains.has(domain_name):
		return domains[domain_name].custom_target_channels
	return []

## Sets whether the domain with the given name is enabled.
## If [param custom_target_channels] is provided, it will be used as the list of channels that messages from the given domain will be sent to.
## It can be provided as a string (if only one channel is used), or an array of strings (if multiple channels are used).
## Otherwise, the default channels will be used.
func set_domain_enabled(domain_name : String, enabled : bool, custom_target_channels : Variant = []) -> void:
	var pruned_target_channels = []

	if custom_target_channels is String:
		custom_target_channels = [custom_target_channels]

	# Prune the array to ensure only string content is used.
	if custom_target_channels is Array:
		for entry in custom_target_channels:
			if entry is String or entry is StringName:
				pruned_target_channels.push_back(entry)
	else:
		push_error("Attempt to set a custom target channel for domain {domain_name} with an invalid value: {custom_target_channels}. The value must be a string or an array of strings. Default channels will be used instead.".format({
			"domain_name": domain_name,
			"custom_target_channels": custom_target_channels
		}))

	domains[domain_name] = {"enabled": enabled, "custom_target_channels": pruned_target_channels}

## Checks whether the domain with the given name is enabled.
## The domain name "" (empty string) is the default one for all newly created messages,
## and is designed to always be enabled.
func is_domain_enabled(domain_name : String) -> bool:
	if domain_name == "":
		return true
	
	if domains.has(domain_name) and domains[domain_name].enabled == true:
		return true
	
	return false

## Returns an available channel with the given ID (if one exists), otherwise null.
func get_channel(channel_id : String) -> LoggieMsgChannel:
	if available_channels.has(channel_id):
		return available_channels[channel_id]
	return null

## Adds a new channel for sending messages to.
## Multiple channels with the same ID can not be added, so make sure your ID
## does not clash with one of the existing channels' IDs, which are:
## [param terminal], [param discord], [param slack].
func add_channel(channel : LoggieMsgChannel):
	if not available_channels.has(channel.ID):
		available_channels[channel.ID] = channel
	else:
		push_error("Attempt to add a channel with ID {ID} failed, a channel with that ID already exists in Loggie.".format({
			"ID": channel.ID
		}))

## Gets a [LoggiePreset] message with the given ID, or creates one if it doesn't exist and returns it.
## The ID cannot be an empty string.
func preset(id : String) -> LoggiePreset:
	if id.is_empty():
		push_error("Attempt to create or get a LoggiePreset with an empty ID - not allowed.")
		return null
	
	if presets.has(id):
		return presets[id]
	else:
		var newPreset : LoggiePreset = LoggiePreset.new()
		newPreset.use_logger(self)
		presets[id] = newPreset
		return newPreset

## Creates a new [LoggieMsg] out of the given [param msg] and extra arguments (by converting them to strings and concatenating them to the msg).
## You may continue to modify the [LoggieMsg] with additional functions from that class, then when you are ready to output it, use methods like:
## [method LoggieMsg.info], [method LoggieMsg.warn], etc.
func msg(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> LoggieMsg:
	var loggieMsg = LoggieMsg.new(message, arg1, arg2, arg3, arg4, arg5)
	loggieMsg.use_logger(self)
	return loggieMsg

## A shortcut method that instantly creates a [LoggieMsg] with the given arguments and outputs it at the info level.
## Can be used when you have no intention of customizing a LoggieMsg in any way using helper methods.
## For customization, use [method msg] instead.
func info(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> LoggieMsg:
	return msg(message, arg1, arg2, arg3, arg4, arg5).info()

## A shortcut method that instantly creates a [LoggieMsg] with the given arguments and outputs it at the warn level.
## Can be used when you have no intention of customizing a LoggieMsg in any way using helper methods.
## For customization, use [method msg] instead.
func warn(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> LoggieMsg:
	return msg(message, arg1, arg2, arg3, arg4, arg5).warn()

## A shortcut method that instantly creates a [LoggieMsg] with the given arguments and outputs it at the error level.
## Can be used when you have no intention of customizing a LoggieMsg in any way using helper methods.
## For customization, use [method msg] instead.
func error(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> LoggieMsg:
	return msg(message, arg1, arg2, arg3, arg4, arg5).error()

## A shortcut method that instantly creates a [LoggieMsg] with the given arguments and outputs it at the debug level.
## Can be used when you have no intention of customizing a LoggieMsg in any way using helper methods.
## For customization, use [method msg] instead.
func debug(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> LoggieMsg:
	return msg(message, arg1, arg2, arg3, arg4, arg5).debug()

## A shortcut method that instantly creates a [LoggieMsg] with the given arguments and outputs it at the notice level.
## Can be used when you have no intention of customizing a LoggieMsg in any way using helper methods.
## For customization, use [method msg] instead.
func notice(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> LoggieMsg:
	return msg(message, arg1, arg2, arg3, arg4, arg5).notice()

## Returns the path to the directory from which within this script is running.
func get_directory_path() -> String:
	return get_script().resource_path.get_base_dir()

## Returns a [LoggieMsg] that comes inserted with stylized content describing the stack trace obtained via [method get_stack].
## This function only works in debug builds, and on the main thread, because it uses [method get_stack].
## Read more about why in that function's documentation.
func stack() -> LoggieMsg:
	if !OS.has_feature("debug"):
		return msg()

	const FALLBACK_TXT_TO_FORMAT = "{index}: {fn_name}:{line} (in {source_path})"
	var stack = get_stack()
	var stack_msg = msg()
	
	var text_to_format = settings.format_stacktrace_entry if is_instance_valid(settings) else FALLBACK_TXT_TO_FORMAT
	
	stack.reverse()
	
	for index in stack.size():
		var file_name = stack[index].source.get_file().get_basename()

		if settings.skipped_filenames_in_stack_trace.has(file_name):
			continue

		var entry_msg = LoggieMsg.new()
		entry_msg.add(text_to_format.format({
			"index": index,
			"source_path": stack[index].source,
			"fn_name": stack[index].function,
			"line": stack[index].line
		}))

		if index == 0 or index < stack.size():
			entry_msg.prefix("\n  ")
			entry_msg.endseg()

		stack_msg.add(entry_msg)

	return stack_msg

## Changes the values of certain members of [member settings] to values which are optimal for
## use in production builds. Used when [member LoggieSettings.enforce_optimal_settings_in_release_build]
## needs to be enforced.
func apply_production_optimal_settings():
	self.settings.msg_format_mode = LoggieEnums.MsgFormatMode.PLAIN
	self.settings.box_characters_mode = LoggieEnums.BoxCharactersMode.COMPATIBLE
