extends DialogicSubsystem
## Subsystem to save and load game states.
##
## This subsystem has many different helper methods to save Dialogic or custom
## game data to named save slots.
##
## You can listen to saves via [signal saved]. \
## If you want to save, you can call [method save]. \


## Emitted when a save happened with the following info:
## [br]
## Key           |   Value Type  | Value [br]
## -----------   | ------------- | ----- [br]
## `slot_name`   | [type String] | The name of the slot that the game state was saved to. [br]
## `is_autosave` | [type bool]   | `true`, if the save was an autosave. [br]
signal saved(info: Dictionary)


## The directory that will be saved to.
const SAVE_SLOTS_DIR := "user://dialogic/saves/"

## The project settings key for the auto-save enabled settings.
const AUTO_SAVE_SETTINGS := "dialogic/save/autosave"

## The project settings key for the auto-save mode settings.
const AUTO_SAVE_MODE_SETTINGS := "dialogic/save/autosave_mode"

## Temporarily stores a taken screen capture when using [take_slot_image()].
enum ThumbnailMode {NONE, TAKE_AND_STORE, STORE_ONLY}
var latest_thumbnail : Image = null


## The different types of auto-save triggers.
## If one of these occurs in the game, an auto-save may happen
## if [member autosave_enabled] is `true`.
enum AutoSaveMode {
	## Includes timeline start, end, and jump events.
	ON_TIMELINE_JUMPS = 0,
	## Saves after a certain time interval.
	ON_TIMER = 1,
	## Saves after every text event.
	ON_TEXT_EVENT = 2
}

## Whether the auto-save feature is enabled.
## The initial value can be set in the project settings via th Dialogic editor.
##
## This can be toggled during the game.
var autosave_enabled := false:
	set(enabled):
		autosave_enabled = enabled

		if enabled:
			autosave_timer.start()
		else:
			autosave_timer.stop()


## Under what conditions the auto-save feature will trigger if
## [member autosave_enabled] is `true`.
var autosave_mode := AutoSaveMode.ON_TIMELINE_JUMPS

## After what time interval the auto-save feature will trigger if
## [member autosave_enabled] is `true` and [member autosave_mode] is
## `AutoSaveMode.ON_TIMER`.
var autosave_time := 60:
	set(timer_time):
		autosave_timer.wait_time = timer_time


#region STATE
####################################################################################################

## Built-in, called by DialogicGameHandler.
func clear_game_state(_clear_flag := DialogicGameHandler.ClearFlags.FULL_CLEAR) -> void:
	_make_sure_slot_dir_exists()

#endregion


#region MAIN METHODS
####################################################################################################

## Saves the current state to the given slot.
## If no slot is given, the default slot is used. You can change this name in
## the Dialogic editor.
## If you want to save to the last used slot, you can get its slot name with the
## [method get_latest_slot()] method.
func save(slot_name := "", is_autosave := false, thumbnail_mode := ThumbnailMode.TAKE_AND_STORE, slot_info := {}) -> Error:
	# check if to save (if this is an autosave)
	if is_autosave and !autosave_enabled:
		return OK

	if slot_name.is_empty():
		slot_name = get_default_slot()

	set_latest_slot(slot_name)

	var save_error := save_file(slot_name, 'state.txt', dialogic.get_full_state())

	if save_error:
		return save_error

	if thumbnail_mode == ThumbnailMode.TAKE_AND_STORE:
		take_thumbnail()
		save_slot_thumbnail(slot_name)
	elif thumbnail_mode == ThumbnailMode.STORE_ONLY:
		save_slot_thumbnail(slot_name)

	if slot_info:
		set_slot_info(slot_name, slot_info)

	saved.emit({"slot_name": slot_name, "is_autosave": is_autosave})
	print('[Dialogic] Saved to slot "'+slot_name+'".')
	return OK


## Loads all info from the given slot in the DialogicGameHandler (Dialogic Autoload).
## If no slot is given, the default slot is used.
## To check if something is saved in that slot use has_slot().
## If the slot does not exist, this method will fail.
func load(slot_name := "") -> Error:
	if slot_name.is_empty(): slot_name = get_default_slot()

	if !has_slot(slot_name):
		printerr("[Dialogic Error] Tried loading from invalid save slot '"+slot_name+"'.")
		return ERR_FILE_NOT_FOUND

	var set_latest_error := set_latest_slot(slot_name)
	if set_latest_error:
		push_error("[Dialogic Error]: Failed to store latest slot to global info. Error %d '%s'" % [set_latest_error, error_string(set_latest_error)])

	var state: Dictionary = load_file(slot_name, 'state.txt', {})
	dialogic.load_full_state(state)

	if state.is_empty():
		return FAILED
	else:
		return OK


## Saves a variable to a file in the given slot.
##
## Be aware, the [param slot_name] will be used as a filesystem folder name.
## Some operating systems do not support every character in folder names.
## It is recommended to use only letters, numbers, and underscores.
##
## This method allows you to build your own save and load system.
## You may be looking for the simple [method save] method to save the game state.
func save_file(slot_name: String, file_name: String, data: Variant) -> Error:
	if slot_name.is_empty():
		slot_name = get_default_slot()

	if slot_name.is_empty():
		push_error("[Dialogic Error]: No fallback slot name set.")
		return ERR_FILE_NOT_FOUND

	if !has_slot(slot_name):
		add_empty_slot(slot_name)

	var encryption_password := get_encryption_password()
	var file: FileAccess

	if encryption_password.is_empty():
		file = FileAccess.open(SAVE_SLOTS_DIR.path_join(slot_name).path_join(file_name), FileAccess.WRITE)
	else:
		file = FileAccess.open_encrypted_with_pass(SAVE_SLOTS_DIR.path_join(slot_name).path_join(file_name), FileAccess.WRITE, encryption_password)

	if file:
		file.store_var(data)
		return OK
	else:
		var error := FileAccess.get_open_error()
		push_error("[Dialogic Error]: Could not save slot to file. Error: %d '%s'" % [error, error_string(error)])
		return error


## Loads a file using [param slot_name] and returns the contained info.
##
## This method allows you to build your own save and load system.
## You may be looking for the simple [method load] method to load the game state.
func load_file(slot_name: String, file_name: String, default: Variant) -> Variant:
	if slot_name.is_empty(): slot_name = get_default_slot()

	var path := get_slot_path(slot_name).path_join(file_name)
	print("trying to load ", path)
	if FileAccess.file_exists(path):
		var encryption_password := get_encryption_password()
		var file: FileAccess

		if encryption_password.is_empty():
			file = FileAccess.open(path, FileAccess.READ)
		else:
			file = FileAccess.open_encrypted_with_pass(path, FileAccess.READ, encryption_password)

		if file:
			return file.get_var()
		else:
			push_error(FileAccess.get_open_error())
	print("Does not exist!")
	return default


## Data set in global info can be accessed unrelated to the save slots.
## For instance, you may want to store game settings in here, as they
## affect the game globally unrelated to the slot used.
func set_global_info(key: String, value: Variant) -> Error:
	var global_info := ConfigFile.new()
	var encryption_password := get_encryption_password()

	if encryption_password.is_empty():
		var load_error := global_info.load(SAVE_SLOTS_DIR.path_join('global_info.txt'))
		if load_error:
			printerr("[Dialogic Error]: Couldn't access global saved info file.")
			return load_error

		else:
			global_info.set_value('main', key, value)
			return global_info.save(SAVE_SLOTS_DIR.path_join('global_info.txt'))

	else:
		var load_error := global_info.load_encrypted_pass(SAVE_SLOTS_DIR.path_join('global_info.txt'), encryption_password)
		if load_error:
			printerr("[Dialogic Error]: Couldn't access global saved info file.")
			return load_error

		else:
			global_info.set_value('main', key, value)
			return global_info.save_encrypted_pass(SAVE_SLOTS_DIR.path_join('global_info.txt'), encryption_password)


## Access the data unrelated to a save slot.
## First, the data must have been set with [method set_global_info].
func get_global_info(key: String, default: Variant) -> Variant:
	var global_info := ConfigFile.new()
	var encryption_password := get_encryption_password()

	if encryption_password.is_empty():

		if global_info.load(SAVE_SLOTS_DIR.path_join('global_info.txt')) == OK:
			return global_info.get_value('main', key, default)

		printerr("[Dialogic Error]: Couldn't access global saved info file.")

	elif global_info.load_encrypted_pass(SAVE_SLOTS_DIR.path_join('global_info.txt'), encryption_password) == OK:
		return global_info.get_value('main', key, default)

	return default


## Gets the encryption password from the project settings if it has been set.
## If no password has been set, an empty string is returned.
func get_encryption_password() -> String:
	if OS.is_debug_build() and ProjectSettings.get_setting('dialogic/save/encryption_on_exports_only', true):
		return ""
	return ProjectSettings.get_setting("dialogic/save/encryption_password", "")

#endregion


#region SLOT HELPERS
####################################################################################################
## Returns a list of all available slots. Useful for iterating over all slots,
## e.g., when building a UI with all save slots.
func get_slot_names() -> Array[String]:
	var save_folders: Array[String] = []

	if DirAccess.dir_exists_absolute(SAVE_SLOTS_DIR):
		var directory := DirAccess.open(SAVE_SLOTS_DIR)
		var _list_dir := directory.list_dir_begin()
		var file_name := directory.get_next()

		while not file_name.is_empty():

			if directory.current_is_dir() and not file_name.begins_with("."):
				save_folders.append(file_name)

			file_name = directory.get_next()

		return save_folders

	return []


## Returns true if the given slot exists.
func has_slot(slot_name: String) -> bool:
	if slot_name.is_empty():
		slot_name = get_default_slot()

	return slot_name in get_slot_names()


## Removes all the given slot along with all it's info/files.
func delete_slot(slot_name: String) -> Error:
	var path := SAVE_SLOTS_DIR.path_join(slot_name)

	if DirAccess.dir_exists_absolute(path):
		var directory := DirAccess.open(path)
		if not directory:
			return DirAccess.get_open_error()
		var _list_dir := directory.list_dir_begin()
		var file_name := directory.get_next()

		while not file_name.is_empty():
			var remove_error := directory.remove(file_name)
			if remove_error:
				push_warning("[Dialogic Error]: Encountered error while removing '%s': %d\t%s" % [path.path_join(file_name), remove_error, error_string(remove_error)])
			file_name = directory.get_next()

		# Delete the folder.
		return directory.remove(SAVE_SLOTS_DIR.path_join(slot_name))

	push_warning("[Dialogic Warning]: Save slot '%s' has already been deleted." % path)
	return OK


## This adds a new save folder with the given name
func add_empty_slot(slot_name: String) -> Error:
	if DirAccess.dir_exists_absolute(SAVE_SLOTS_DIR):
		var directory := DirAccess.open(SAVE_SLOTS_DIR)
		if directory:
			return directory.make_dir(slot_name)
		return DirAccess.get_open_error()

	push_error("[Dialogic Error]: Path to '%s' does not exist." % SAVE_SLOTS_DIR)
	return ERR_FILE_BAD_PATH


## Reset the state of the given save folder (or default)
func reset_slot(slot_name := "") -> Error:
	if slot_name.is_empty():
		slot_name = get_default_slot()

	return save_file(slot_name, 'state.txt', {})


## Returns the full path to the given slot folder
func get_slot_path(slot_name: String) -> String:
	return SAVE_SLOTS_DIR.path_join(slot_name)


## Returns the default slot name defined in the dialogic settings
func get_default_slot() -> String:
	return ProjectSettings.get_setting('dialogic/save/default_slot', 'Default')


## Returns the latest slot or empty if nothing was saved yet
func get_latest_slot() -> String:
	var latest_slot: String = ""

	if Engine.get_main_loop().has_meta('dialogic_latest_saved_slot'):
		latest_slot = Engine.get_main_loop().get_meta('dialogic_latest_saved_slot', '')

	else:
		latest_slot = get_global_info('latest_save_slot', '')
		Engine.get_main_loop().set_meta('dialogic_latest_saved_slot', latest_slot)


	if !has_slot(latest_slot):
		return ''

	return latest_slot


func set_latest_slot(slot_name:String) -> Error:
	Engine.get_main_loop().set_meta('dialogic_latest_saved_slot', slot_name)
	return set_global_info('latest_save_slot', slot_name)


func _make_sure_slot_dir_exists() -> Error:
	if not DirAccess.dir_exists_absolute(SAVE_SLOTS_DIR):
		var make_dir_result := DirAccess.make_dir_recursive_absolute(SAVE_SLOTS_DIR)
		if make_dir_result:
			return make_dir_result

	var global_info_path := SAVE_SLOTS_DIR.path_join('global_info.txt')

	if not FileAccess.file_exists(global_info_path):
		var config := ConfigFile.new()
		var password := get_encryption_password()

		if password.is_empty():
			return config.save(global_info_path)

		else:
			return config.save_encrypted_pass(global_info_path, password)

	return OK

#endregion


#region SLOT INFO
####################################################################################################

func set_slot_info(slot_name:String, info: Dictionary) -> Error:
	if slot_name.is_empty():
		slot_name = get_default_slot()

	return save_file(slot_name, 'info.txt', info)


func get_slot_info(slot_name := "") -> Dictionary:
	if slot_name.is_empty():
		slot_name = get_default_slot()

	return load_file(slot_name, 'info.txt', {})

#endregion


#region SLOT IMAGE
####################################################################################################

## This method creates a thumbnail of the current game view, it allows to
## save the game without having the UI on the save slot image.
## The thumbnail will be stored in [member latest_thumbnail].
##
## Call this method before opening your save & load menu.
## After that, call [method save] with [constant ThumbnailMode.STORE_ONLY].
## The [method save] will automatically use the stored thumbnail.
func take_thumbnail() -> void:
	latest_thumbnail = get_viewport().get_texture().get_image()


## No need to call from outside.
## Used to store the latest thumbnail to the given slot.
func save_slot_thumbnail(slot_name: String) -> Error:
	if latest_thumbnail:
		var path := get_slot_path(slot_name).path_join('thumbnail.png')
		return latest_thumbnail.save_png(path)

	push_warning("[Dialogic Warning]: No thumbnail has been set yet.")
	return OK


## Returns the thumbnail of the given slot.
func get_slot_thumbnail(slot_name: String) -> ImageTexture:
	if slot_name.is_empty():
		slot_name = get_default_slot()

	var path := get_slot_path(slot_name).path_join('thumbnail.png')

	if FileAccess.file_exists(path):
		return ImageTexture.create_from_image(Image.load_from_file(path))

	return null

#endregion


#region AUTOSAVE
####################################################################################################
## Reference to the autosave timer.
var autosave_timer := Timer.new()


func _ready() -> void:
	autosave_timer.one_shot = true
	DialogicUtil.update_timer_process_callback(autosave_timer)
	autosave_timer.name = "AutosaveTimer"
	var _result := autosave_timer.timeout.connect(_on_autosave_timer_timeout)
	add_child(autosave_timer)

	autosave_enabled = ProjectSettings.get_setting(AUTO_SAVE_SETTINGS, autosave_enabled)
	autosave_mode = ProjectSettings.get_setting(AUTO_SAVE_MODE_SETTINGS, autosave_mode)

	_result = dialogic.event_handled.connect(_on_dialogic_event_handled)
	_result = dialogic.timeline_started.connect(_on_start_or_end_autosave)
	_result = dialogic.timeline_ended.connect(_on_start_or_end_autosave)

	_on_autosave_timer_timeout()


func _on_autosave_timer_timeout() -> void:
	if autosave_mode == AutoSaveMode.ON_TIMER:
		perform_autosave()

	autosave_time = ProjectSettings.get_setting('dialogic/save/autosave_delay', autosave_time)
	autosave_timer.start(autosave_time)


func _on_dialogic_event_handled(event: DialogicEvent) -> void:
	if event is DialogicJumpEvent:

		if autosave_mode == AutoSaveMode.ON_TIMELINE_JUMPS:
			perform_autosave()

	if event is DialogicTextEvent:

		if autosave_mode == AutoSaveMode.ON_TEXT_EVENT:
			perform_autosave()


func _on_start_or_end_autosave() -> void:
	if autosave_mode == AutoSaveMode.ON_TIMELINE_JUMPS:
		perform_autosave()


## Perform an autosave.
## This method will be called automatically if the auto-save mode is enabled.
func perform_autosave() -> Error:
	return save("", true)

#endregion
