# ############################################################################ #
# Copyright © 2018-2022 Paul Joannon
# Copyright © 2019-2022 Frédéric Maquin <fred@ephread.com>
# Licensed under the MIT License.
# See LICENSE in the project root for license information.
# ############################################################################ #

extends Node

class_name InkPlayer

# ############################################################################ #
# Imports
# ############################################################################ #

var InkRuntime = load("res://addons/inkgd/runtime.gd")
var InkResource = load("res://addons/inkgd/editor/import_plugins/ink_resource.gd")
var InkStory = load("res://addons/inkgd/runtime/story.gd")
var InkFunctionResult = load("res://addons/inkgd/runtime/extra/function_result.gd")


# ############################################################################ #
# Signals
# ############################################################################ #

## Emitted when the ink runtime encountered an exception. Exception are
## usually not recoverable as they corrupt the state. `stack_trace` is
## an optional PoolStringArray containing a stack trace, for logging purposes.
signal exception_raised(message, stack_trace)

## Emitted when the _story encountered an error. These errors are usually
## recoverable.
signal error_encountered(message, type)

## Emitted with `true` when the runtime had loaded the JSON file and created
## the _story. If an error was encountered, `successfully` will be `false` and
## and error will appear Godot's output.
signal loaded(successfully)

## Emitted with the text and tags of the current line when the _story
## successfully continued.
signal continued(text, tags)

## Emitted when using `continue_async`, if the time spent evaluating the ink
## exceeded the alloted time.
signal interrupted()

## Emitted when the player should pick a choice.
signal prompt_choices(choices)

## Emitted when a choice was reported back to the runtime.
signal choice_made(choice)

## Emitted when an external function is about to evaluate.
signal function_evaluating(function_name, arguments)

## Emitted when an external function evaluated.
signal function_evaluated(function_name, arguments, function_result)

## Emitted when a valid path string was choosen.
signal path_choosen(path, arguments)

## Emitted when the _story ended.
signal ended()


# ############################################################################ #
# Exported Properties
# ############################################################################ #

## The compiled Ink file (.json) to play.
@export var ink_file: Resource

## When `true` the _story will be created in a separate threads, to
## prevent the UI from freezing if the _story is too big. Note that
## on platforms where threads aren't available, the value of this
## property is ignored.
@export var loads_in_background: bool = true


# ############################################################################ #
# Properties
# ############################################################################ #

# These properties aren't exported because they depend on the runtime or the
# story to be set. The runtime insn't always available upon instantiation,
# and the story is only available after calling 'create_story' so rather than
# losing the values and confusing everybody, those properties are code-only.

## `true` to allow external function fallbacks, `false` otherwise. If this
## property is `false` and the appropriate function hasn't been binded, the
## _story will output an error.
var allow_external_function_fallbacks: bool: get = get_aeff, set = set_aeff
func set_aeff(value: bool):
	if _story == null:
		_push_null_story_error()
		return

	_story.allow_external_function_fallbacks = value
func get_aeff() -> bool:
	if _story == null:
		_push_null_story_error()
		return false

	return _story.allow_external_function_fallbacks

# skips saving global values that remain equal to the initial values that were
# declared in Ink.
var do_not_save_default_values: bool: get = get_dnsdv, set = set_dnsdv
func set_dnsdv(value: bool):
	var ink_runtime = _ink_runtime.get_ref()
	if ink_runtime == null:
		_push_null_runtime_error()
		return false

	ink_runtime.dont_save_default_values = value
func get_dnsdv() -> bool:
	var ink_runtime = _ink_runtime.get_ref()
	if ink_runtime == null:
		_push_null_runtime_error()
		return false

	return ink_runtime.dont_save_default_values

## Uses `assert` instead of `push_error` to report critical errors, thus
## making them more explicit during development.
var stop_execution_on_exception: bool: get = get_seoex, set = set_seoex
func set_seoex(value: bool):
	var ink_runtime = _ink_runtime.get_ref()
	if ink_runtime == null:
		_push_null_runtime_error()
		return

	ink_runtime.stop_execution_on_exception = value
func get_seoex() -> bool:
	var ink_runtime = _ink_runtime.get_ref()
	if ink_runtime == null:
		_push_null_runtime_error()
		return false

	return ink_runtime.stop_execution_on_exception

## Uses `assert` instead of `push_error` to report _story errors, thus
## making them more explicit during development.
var stop_execution_on_error: bool: get = get_seoer, set = set_seoer
func set_seoer(value: bool):
	var ink_runtime = _ink_runtime.get_ref()
	if ink_runtime == null:
		_push_null_runtime_error()
		return

	ink_runtime.stop_execution_on_error = value

func get_seoer() -> bool:
	var ink_runtime = _ink_runtime.get_ref()
	if ink_runtime == null:
		_push_null_runtime_error()
		return false

	return ink_runtime.stop_execution_on_error


# ############################################################################ #
# Read-only Properties
# ############################################################################ #

## `true` if the _story can continue (i. e. is not expecting a choice to be
## choosen and hasn't reached the end).
var can_continue: bool: get = get_can_continue
func get_can_continue() -> bool:
	if _story == null:
		_push_null_story_error()
		return false

	return _story.can_continue


## If `continue_async` was called (with milliseconds limit > 0) then this
## property will return false if the ink evaluation isn't yet finished, and
## you need to call it again in order for the continue to fully complete.
var async_continue_complete: bool: get = get_async_continue_complete
func get_async_continue_complete() -> bool:
	if _story == null:
		_push_null_story_error()
		return false

	return _story.async_continue_complete


## The content of the current line.
var current_text: String: get = get_current_text
func get_current_text() -> String:
	if _story == null:
		_push_null_story_error()
		return ""


	return _story.current_text


## The current choices. Empty is there are no choices for the current line.
var current_choices: Array: get = get_current_choices
func get_current_choices() -> Array:
	if _story == null:
		_push_null_story_error()
		return []

	return _story.current_choices.duplicate()


## The current tags. Empty is there are no tags for the current line.
var current_tags: Array: get = get_current_tags
func get_current_tags() -> Array:
	if _story == null:
		_push_null_story_error()
		return []

	if _story.current_tags == null:
		return []

	return _story.current_tags


## The global tags for the _story. Empty if none have been declared.
var global_tags: Array: get = get_global_tags
func get_global_tags() -> Array:
	if _story == null:
		_push_null_story_error()
		return []

	if _story.global_tags == null:
		return []

	return _story.global_tags


## `true` if the _story currently has choices, `false` otherwise.
var has_choices: bool: get = get_has_choices
func get_has_choices() -> bool:
	return !self.current_choices.is_empty()


## The name of the current flow.
var current_flow_name: String: get = get_current_flow_name
func get_current_flow_name() -> String:
	if _story == null:
		_push_null_story_error()
		return ""

	return _story.state.current_flow_name


## The names of all flows currently alive.
var alive_flow_names: Array: get = get_alive_flow_names
func get_alive_flow_names() -> Array:
	if _story == null:
		_push_null_story_error()
		return []

	return _story.alive_flow_names


## `true` if the current flow is the default flow.
var current_flow_is_default_flow: bool: get = get_current_flow_is_default_flow
func get_current_flow_is_default_flow() -> bool:
	if _story == null:
		_push_null_story_error()
		return false

	return _story.current_flow_is_default_flow


## The current story path.
var current_path: String: get = get_current_path
func get_current_path() -> String:
	if _story == null:
		_push_null_story_error()
		return ""

	var path = _story.state.current_path_string
	if path == null:
		return ""
	else:
		return path


# ############################################################################ #
# Private Properties
# ############################################################################ #

var _ink_runtime: WeakRef = WeakRef.new()
var _story: InkStory = null
var _thread: Thread
var _manages_runtime: bool = false


# ############################################################################ #
# Initialization
# ############################################################################ #

func _init():
	name = "InkPlayer"


# ############################################################################ #
# Overrides
# ############################################################################ #

func _exit_tree():
	call_deferred("_remove_runtime")


# ############################################################################ #
# Methods
# ############################################################################ #

## Creates the _story, based on the value of `ink_file`. The result of this
## method is reported through the 'story_loaded' signal.
func create_story() -> int:
	if ink_file == null:
		_push_error("'ink_file' is null, did Godot import the resource correctly?")
		call_deferred("emit_signal", "loaded", false)
		return ERR_CANT_CREATE

	if !("json" in ink_file) || typeof(ink_file.json) != TYPE_STRING:
		_push_error(
				"'ink_file' doesn't have the appropriate resource type." + \
				"Are you sure you imported a JSON file?"
		)
		call_deferred("emit_signal", "loaded", false)
		return ERR_CANT_CREATE

	if loads_in_background && _current_platform_supports_threads():
		_thread = Thread.new()
		var error = _thread.start(_async_create_story.bind(ink_file.json, _ink_runtime.get_ref()))
		if error != OK:
			printerr("[inkgd] [ERROR] Could not start the thread: error code %d", error)
			call_deferred("emit_signal", "loaded", false)
			return error
		else:
			return OK
	else:
		call_deferred("_create_and_finalize_story", ink_file.json, _ink_runtime.get_ref())
		return OK


## Reset the Story back to its initial state as it was when it was
## first constructed.
func reset() -> void:
	if _story == null:
		_push_null_story_error()
		return

	_story.reset_state()


## Destroys the current story.
func destroy() -> void:
	_story = null

# ############################################################################ #
# Methods | Story Flow
# ############################################################################ #

## Continues the story.
func continue_story() -> String:
	if _story == null:
		_push_null_story_error()
		return ""

	var text: String = ""
	if self.can_continue:
		_story.continue_story()

		text = self.current_text

	elif self.has_choices:
		emit_signal("prompt_choices", self.current_choices)
	else:
		emit_signal("ended")

	return text

## An "asynchronous" version of `continue_story` that only partially evaluates
## the ink, with a budget of a certain time limit. It will exit ink evaluation
## early if the evaluation isn't complete within the time limit, with the
## `async_continue_complete` property being false. This is useful if the
## evaluation takes a long time, and you want to distribute it over multiple
## game frames for smoother animation. If you pass a limit of zero, then it will
## fully evaluate the ink in the same way as calling continue_story.
##
## To get notified when the evaluation is exited early, you can connect to the
## `interrupted` signal.
func continue_story_async(millisecs_limit_async: float) -> void:
	if _story == null:
		_push_null_story_error()
		return

	if self.can_continue:
		_story.continue_async(millisecs_limit_async)

		if !self.async_continue_complete:
			emit_signal("interrupted")
			return

	elif self.has_choices:
		emit_signal("prompt_choices", self.current_choices)
	else:
		emit_signal("ended")

## Continue the story until the next choice point or until it runs out of
## content. This is as opposed to `continue` which only evaluates one line
## of output at a time. It returns the resulting text evaluated by the ink
## engine, concatenated together.
func continue_story_maximally() -> String:
	if _story == null:
		_push_null_story_error()
		return ""

	var text: String = ""
	if self.can_continue:
		_story.continue_story_maximally()

		text = self.current_text

	elif self.has_choices:
		emit_signal("prompt_choices", self.current_choices)
	else:
		emit_signal("ended")

	return text

## Chooses a choice. If the _story is not currently expected choices or
## the index is out of bounds, this method does nothing.
func choose_choice_index(index: int) -> void:
	if _story == null:
		_push_null_story_error()
		return

	if index >= 0 && index < self.current_choices.size():
		_story.choose_choice_index(index);


## Moves the _story to the specified knot/stitch/gather. This method
## will throw an error through the 'exception' signal if the path string
## does not match any known path.
func choose_path(path: String) -> void:
	if _story == null:
		_push_null_story_error()
		return

	_story.choose_path_string(path)


## Switches the flow, creating a new flow if it doesn't exist.
func switch_flow(flow_name: String) -> void:
	if _story == null:
		_push_null_story_error()
		return

	_story.switch_flow(flow_name)


## Switches the the default flow.
func switch_to_default_flow() -> void:
	if _story == null:
		_push_null_story_error()
		return

	_story.switch_to_default_flow()


## Remove the given flow.
func remove_flow(flow_name: String) -> void:
	if _story == null:
		_push_null_story_error()
		return

	_story.remove_flow(flow_name)


# ############################################################################ #
# Methods | Tags
# ############################################################################ #

## Returns the tags declared at the given path.
func tags_for_content_at_path(path: String) -> Array:
	if _story == null:
		_push_null_story_error()
		return []

	return _story.tags_for_content_at_path(path)


# ############################################################################ #
# Methods | Visit Count
# ############################################################################ #

## Returns the visit count of the given path.
func visit_count_at_path(path: String) -> int:
	if _story == null:
		_push_null_story_error()
		return 0

	return _story.state.visit_count_at_path_string(path)


# ############################################################################ #
# Methods | State Management
# ############################################################################ #

## Gets the current state as a JSON string. It can then be saved somewhere.
func get_state() -> String:
	if _story == null:
		_push_null_story_error()
		return ""

	return _story.state.to_json()


## If you have a large story, and saving state to JSON takes too long for your
## framerate, you can temporarily freeze a copy of the state for saving on
## a separate thread. Internally, the engine maintains a "diff patch".
## When you've finished saving your state, call `background_save_complete`
## and that diff patch will be applied, allowing the story to continue
## in its usual mode.
func copy_state_for_background_thread_save() -> String:
	if _story == null:
		_push_null_story_error()
		return ""

	return _story.copy_state_for_background_thread_save().to_json()


## See `copy_state_for_background_thread_save`. This method releases the
## "frozen" save state, applying its patch that it was using internally.
func background_save_complete() -> void:
	if _story == null:
		_push_null_story_error()
		return

	_story.background_save_complete()


## Sets the state from a JSON string.
func set_state(state: String) -> void:
	if _story == null:
		_push_null_story_error()
		return

	_story.state.load_json(state)


## Saves the current state to the given path.
func save_state_to_path(path: String):
	if _story == null:
		_push_null_story_error()
		return

	if !path.begins_with("res://") && !path.begins_with("user://"):
		path = "user://%s" % path

	var file := FileAccess.open(path, FileAccess.WRITE)
	save_state_to_file(file)
	file.close()


## Saves the current state to the file.
func save_state_to_file(file: FileAccess):
	if _story == null:
		_push_null_story_error()
		return

	if file.is_open():
		file.store_string(get_state())

# TODO: Add save and load in background

## Loads the state from the given path.
func load_state_from_path(path: String):
	if _story == null:
		_push_null_story_error()
		return

	if !path.begins_with("res://") && !path.begins_with("user://"):
		path = "user://%s" % path

	var file := FileAccess.open(path, FileAccess.READ)
	load_state_from_file(file)
	file.close()


## Loads the state from the given file.
func load_state_from_file(file: FileAccess):
	if _story == null:
		_push_null_story_error()
		return

	if !file.is_open():
		return

	file.seek(0);
	if file.get_length() > 0:
		_story.state.load_json(file.get_as_text())


# ############################################################################ #
# Methods | Variables
# ############################################################################ #

## Returns the value of variable named 'name' or 'null' if it doesn't exist.
@warning_ignore("shadowed_variable_base_class")
func get_variable(name: String):
	if _story == null:
		_push_null_story_error()
		return null

	return _story.variables_state.get_variable(name)


## Sets the value of variable named 'name'.
@warning_ignore("shadowed_variable_base_class")
func set_variable(name: String, value):
	if _story == null:
		_push_null_story_error()
		return

	_story.variables_state.set_variable(name, value)


# ############################################################################ #
# Methods | Variable Observers
# ############################################################################ #

## Registers an observer for the given variables.
func observe_variables(variable_names: Array, object: Object, method_name: String):
	if _story == null:
		_push_null_story_error()
		return

	_story.observe_variables(variable_names, object, method_name)


## Registers an observer for the given variable.
func observe_variable(variable_name: String, object: Object, method_name: String):
	if _story == null:
		_push_null_story_error()
		return

	_story.observe_variable(variable_name, object, method_name)


## Removes an observer for the given variable name. This method is highly
## specific and will only remove one observer.
func remove_variable_observer(object: Object, method_name: String, specific_variable_name: String) -> void:
	if _story == null:
		_push_null_story_error()
		return

	_story.remove_variable_observer(object, method_name, specific_variable_name)


## Removes all observers registered with the couple object/method_name,
## regardless of which variable they observed.
func remove_variable_observer_for_all_variables(object: Object, method_name: String) -> void:
	if _story == null:
		_push_null_story_error()
		return

	_story.remove_variable_observer(object, method_name)


## Removes all observers observing the given variable.
func remove_all_variable_observers(specific_variable_name: String) -> void:
	if _story == null:
		_push_null_story_error()
		return

	_story.remove_variable_observer(null, null, specific_variable_name)


# ############################################################################ #
# Methods | External Functions
# ############################################################################ #

## Binds an external function.
func bind_external_function(
		func_name: String,
		object: Object,
		method_name: String,
		lookahead_safe = false
) -> void:
	if _story == null:
		_push_null_story_error()
		return

	_story.bind_external_function(func_name, object, method_name, lookahead_safe)


## Unbinds an external function.
func unbind_external_function(func_name: String) -> void:
	if _story == null:
		_push_null_story_error()
		return

	_story.unbind_external_function(func_name)


# ############################################################################ #
# Methods | Functions
# ############################################################################ #

func has_function(function_name: String) -> bool:
	return _story.has_function(function_name)

## Evaluate a given ink function, returning its return value (but not
## its output).
func evaluate_function(function_name: String, arguments = []) -> InkFunctionResult:
	if _story == null:
		_push_null_story_error()
		return null

	var result = _story.evaluate_function(function_name, arguments, true)

	if result != null:
		return InkFunctionResult.new(result["output"], result["result"])
	else:
		return null

# ############################################################################ #
# Methods | Ink List Creation
# ############################################################################ #

## Creates a new empty InkList that's intended to hold items from a particular
## origin list definition.
func create_ink_list_with_origin(single_origin_list_name: String) -> InkList:
	return InkList.new_with_origin(single_origin_list_name, _story)

## Creates a new InkList from the name of a preexisting item.
func create_ink_list_from_item_name(item_name: String) -> InkList:
	return InkList.from_string(item_name, _story)


# ############################################################################ #
# Private Methods | Signal Forwarding
# ############################################################################ #

func _exception_raised(message, stack_trace) -> void:
	emit_signal("exception_raised", message, stack_trace)


func _on_error(message, type) -> void:
	if get_signal_connection_list("error_encountered").size() == 0:
		_push_story_error(message, type)
	else:
		emit_signal("error_encountered", message, type)


func _on_did_continue() -> void:
	emit_signal("continued", self.current_text, self.current_tags)


func _on_make_choice(choice) -> void:
	emit_signal("choice_made", choice.text)


func _on_evaluate_function(function_name, arguments) -> void:
	emit_signal("function_evaluating", function_name, arguments)


func _on_complete_evaluate_function(function_name, arguments, text_output, return_value) -> void:
	var function_result = InkFunctionResult.new(text_output, return_value)
	emit_signal("function_evaluated", function_name, arguments, function_result)


func _on_choose_path_string(path, arguments) -> void:
	emit_signal("path_choosen", path, arguments)


# ############################################################################ #
# Private Methods
# ############################################################################ #

func _create_story(json_story, runtime) -> void:
	_story = InkStory.new(json_story, runtime)


func _async_create_story(json_story, runtime) -> void:
	_create_story(json_story, runtime)
	call_deferred("_async_creation_completed")


func _async_creation_completed() -> void:
	_thread.wait_to_finish()
	_thread = null

	_finalise_story_creation()


func _create_and_finalize_story(json_story, runtime) -> void:
	_create_story(json_story, runtime)
	_finalise_story_creation()


func _finalise_story_creation() -> void:
	_story.connect("on_error", Callable(self, "_on_error"))
	_story.connect("on_did_continue", Callable(self, "_on_did_continue"))
	_story.connect("on_make_choice", Callable(self, "_on_make_choice"))
	_story.connect("on_evaluate_function", Callable(self, "_on_evaluate_function"))
	_story.connect("on_complete_evaluate_function", Callable(self, "_on_complete_evaluate_function"))
	_story.connect("on_choose_path_string", Callable(self, "_on_choose_path_string"))

	var ink_runtime = _ink_runtime.get_ref()
	if ink_runtime == null:
		_push_null_runtime_error()
		emit_signal("loaded", false)
		return

	emit_signal("loaded", true)


func _add_runtime(root) -> void:
	# The InkRuntime is normaly an auto-loaded singleton,
	# but if it's not present, it's added here.
	var runtime: Node
	if root.has_node("__InkRuntime"):
		runtime = root.get_node("__InkRuntime")
	else:
		_manages_runtime = true
		runtime = InkRuntime.init(root)

	if !runtime.is_connected("exception_raised", _exception_raised):
		runtime.connect("exception_raised", _exception_raised)

	_ink_runtime = weakref(runtime)


func _remove_runtime() -> void:
	if _manages_runtime:
		InkRuntime.deinit(get_tree().root)


func _current_platform_supports_threads() -> bool:
	return OS.get_name() != "HTML5"


func _push_null_runtime_error() -> void:
	_push_error(
			"InkRuntime could not found, did you remove it from the tree?"
	)


func _push_null_story_error() -> void:
	_push_error("The _story is 'null', was it loaded properly?")


func _push_story_error(message: String, type: int) -> void:
	if Engine.is_editor_hint():
		match type:
			Ink.ErrorType.ERROR:
				printerr(message)
			Ink.ErrorType.WARNING, Ink.ErrorType.AUTHOR:
				print(message)
	else:
		match type:
			Ink.ErrorType.ERROR:
				push_error(message)
			Ink.ErrorType.WARNING, Ink.ErrorType.AUTHOR:
				push_warning(message)

func _push_error(message: String):
	if Engine.is_editor_hint():
		printerr(message)

		var i = 1
		for stack_element in get_stack():
			if i <= 2:
				i += 1
				continue

			printerr(
				"    ", (i - 2), " - ", stack_element["source"], ":",
				stack_element["line"], " - at function: ", stack_element["function"]
			)
	else:
		push_error(message)
