// /////////////////////////////////////////////////////////////////////////// /
// 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.
// /////////////////////////////////////////////////////////////////////////// /

using Godot;
using System;
using System.Linq;
using System.Collections.Generic;
using System.Diagnostics;

[Tool]

public partial class InkPlayer : Node
{
	#region Signals
	[Signal] public delegate void exception_raised(string message, Godot.Collections.Array<string> stack_trace);
	[Signal] public delegate void error_encountered(string message, int type);
	[Signal] public delegate void loaded(bool successfully);
	[Signal] public delegate void continued(string text, Godot.Collections.Array<string> tags);
	[Signal] public delegate void interrupted();
	[Signal] public delegate void prompt_choices(Godot.Collections.Array<Godot.Object> choices);
	[Signal] public delegate void choice_made(Godot.Object choice);
	[Signal] public delegate void function_evaluating(Godot.Collections.Array<string> function_name, object[] arguments);
	[Signal] public delegate void function_evaluated(Godot.Collections.Array<string> function_name, object[] arguments, Godot.Object function_result);
	[Signal] public delegate void path_choosen(string path, object[] arguments);
	[Signal] public delegate void ended();
	#endregion

	#region Exported properties
	[Export]
	public Godot.Resource ink_file = null;

	[Export]
	public bool loads_in_background = false;
	#endregion

	#region 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.
	public bool allow_external_function_fallbacks
	{
		get {
			if (story == null)
			{
				PushNullStoryError();
				return false;
			}

			return story.allowExternalFunctionFallbacks;
		}

		set {
			if (story == null)
			{
				PushNullStoryError();
				return;
			}

			story.allowExternalFunctionFallbacks = value;
		}
	}

	public bool do_not_save_default_values
	{
		get { return Ink.Runtime.VariablesState.dontSaveDefaultValues; }

		set { Ink.Runtime.VariablesState.dontSaveDefaultValues = value; }
	}

	public bool stop_execution_on_exception = false;

	public bool stop_execution_on_error = false;
	#endregion

	#region Read-only Properties
	public bool can_continue
	{
		get {
			if (story == null)
			{
				PushNullStoryError();
				return false;
			}

			return story.canContinue;
		}
	}

	public bool async_continue_complete
	{
		get {
			if (story == null)
			{
				PushNullStoryError();
				return false;
			}

			return story.asyncContinueComplete;
		}
	}

	public string current_text
	{
		get {
			if (story == null)
			{
				PushNullStoryError();
				return "";
			}

			if (story.currentText == null)
			{
				PushNullStateError("current_choices");
				return "";
			}


			return story.currentText ?? "";
		}
	}

	public Godot.Collections.Array<string> current_choices
	{
		get {
			if (story == null)
			{
				PushNullStoryError();
				return new Godot.Collections.Array<string>();
			}

			if (story.currentChoices == null)
			{
				PushNullStateError("current_choices");
				return new Godot.Collections.Array<string>();
			}

			if (choices != null) {
				return new Godot.Collections.Array<string>(story.currentChoices);
			} else {
				return new Godot.Collections.Array<string>();
			}
		}
	}

	public Godot.Collections.Array<string> current_tags
	{
		get {
			if (story == null)
			{
				PushNullStoryError();
				return new Godot.Collections.Array<string>();
			}

			if (story.currentTags == null)
			{
				PushNullStateError("current_tags");
				return new Godot.Collections.Array<string>();
			}

			if (story.currentTags != null) {
				return new Godot.Collections.Array<string>(story.currentTags);
			} else {
				return new Godot.Collections.Array<string>();
			}
		}
	}

	public Godot.Collections.Array<string> global_tags
	{
		get {
			if (story == null)
			{
				PushNullStoryError();
				return new Godot.Collections.Array<string>();
			}

			if (story.globalTags != null) {
				return new Godot.Collections.Array<string>(story.globalTags);
			} else {
				return new Godot.Collections.Array<string>();
			}
		}
	}

	public bool has_choices
	{
		get { return current_choices.Count > 0; }
	}

	public string current_flow_name
	{
		get {
			if (story == null)
			{
				PushNullStoryError();
				return "";
			}

			return story.state.currentFlowName;
		}
	}

	public string current_path
	{
		get {
			if (story == null)
			{
				PushNullStoryError();
				return "";
			}

			return story.state.currentPathString;
		}
	}
	#endregion

	#region Private Properties
	private Ink.Runtime.Story story = null;
	private Thread thread = null;

	private readonly InkBridger inkBridger = new InkBridger();
	private Dictionary<string, List<FunctionReference>> observers =
		new Dictionary<string, List<FunctionReference>>();
	#endregion

	#region Private Properties
	public InkPlayer() {
		this.Set("name", "InkPlayer");
	}
	#endregion

	#region Methods
	public void create_story()
	{
		if (ink_file == null) {
			PushError("'ink_file' is 'Nil', did Godot import the resource correctly?");
			CallDeferred("emit_signal", "loaded", false);
			return;
		}

		if (!IsValidResource(ink_file))
		{
			PushError(
				"'ink_file' doesn't have the appropriate resource type. Are you sure you imported a JSON file?"
			);
			CallDeferred("emit_signal", "loaded", false);
			return;
		}

		if (loads_in_background && CurrentPlatformSupportsThreads())
		{
			thread = new Thread();
			var error = thread.Start(this, "AsyncCreateStory", (string)ink_file.Get("json"));
			if (error != Error.Ok)
			{
				GD.PrintErr($"[inkgd] [ERROR] Could not start the thread: error code {error}.");
				EmitSignal("loaded", false);
			}
		}
		else
		{
			CreateStory((string)ink_file.Get("json"));
			FinaliseStoryCreation();
		}
	}

	public void reset()
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			story.ResetState();
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}

	public void destroy()
	{
		story = null;
	}
	#endregion

	#region Methods | Story Flow
	public string continue_story()
	{
		if (story == null)
		{
			PushNullStoryError();
			return "";
		}

		var text = "";

		try
		{
			if (can_continue)
			{
				story.Continue();
				text = current_text;
			}
			else if (has_choices)
			{
				EmitSignal("prompt_choices",  new object[] { current_choices });
			}
			else
			{
				EmitSignal("ended");
			}

			return text;
		}
		catch (Exception e)
		{
			HandleException(e);
			return text;
		}
	}

	public void continue_story_async(float millisecs_limit_async)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			if (can_continue)
			{
				story.ContinueAsync(millisecs_limit_async);

				if (!async_continue_complete) {
					EmitSignal("interrupted");
					return;
				}
			}
			else if (has_choices)
			{
				EmitSignal("prompt_choices", new object[] { current_choices });
			}
			else
			{
				EmitSignal("ended");
			}

			return;
		}
		catch (Exception e)
		{
			HandleException(e);
			return;
		}
	}

	public string continue_story_maximally()
	{
		if (story == null)
		{
			PushNullStoryError();
			return "";
		}

		var text = "";

		try
		{
			if (can_continue)
			{
				story.ContinueMaximally();
				text = current_text;
			}
			else if (has_choices)
			{
				EmitSignal("prompt_choices",  new object[] { current_choices });
			}
			else
			{
				EmitSignal("ended");
			}

			return text;
		}
		catch (Exception e)
		{
			HandleException(e);
			return text;
		}
	}

	public void choose_choice_index(int index)
	{
		try
		{
			if (index >= 0 && index < current_choices.Count)
			{
				story.ChooseChoiceIndex(index);
			}
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}

	public void choose_path(string path)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			story.ChoosePathString(path);
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}

	public void switch_flow(string flow_name)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			story.SwitchFlow(flow_name);
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}

	public void switch_to_default_flow()
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			story.SwitchToDefaultFlow();
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}

	public void remove_flow(string flow_name)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			story.RemoveFlow(flow_name);
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}
	#endregion

	#region Methods | Tags
	public Godot.Collections.Array<string> tags_for_content_at_path(string path)
	{
		if (story == null)
		{
			PushNullStoryError();
			return new Godot.Collections.Array<string>();
		}

		try
		{
			var tags = story.TagsForContentAtPath(path);
			if (tags != null) {
				return new Godot.Collections.Array<string>(tags);
			} else {
				return new Godot.Collections.Array<string>();
			}
		}
		catch (Exception e)
		{
			HandleException(e);
			return new Godot.Collections.Array<string>();
		}
	}

	public int visit_count_at_path(string path)
	{
		if (story == null)
		{
			PushNullStoryError();
			return 0;
		}

		try
		{
			return story.state.VisitCountAtPathString(path);
		}
		catch (Exception e)
		{
			HandleException(e);
			return 0;
		}
	}
	#endregion

	#region Methods | State Management
	public string get_state()
	{
		if (story == null)
		{
			PushNullStoryError();
			return "";
		}

		try
		{
			return story.state.ToJson();
		}
		catch (Exception e)
		{
			HandleException(e);
			return "";
		}
	}

	public string copy_state_for_background_thread_save()
	{
		if (story == null)
		{
			PushNullStoryError();
			return "";
		}

		return story.CopyStateForBackgroundThreadSave().ToJson();
	}

	public void background_save_complete()
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		story.BackgroundSaveComplete();
	}

	public void set_state(string state)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			story.state.LoadJson(state);
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}

	public void save_state_to_path(string path)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		string sanitizedPath;
		if (!path.StartsWith("res://") && !path.StartsWith("user://"))
		{
			sanitizedPath = $"user://{path}";
		}
		else
		{
			sanitizedPath = path;
		}

		var file = new File();
		file.Open(sanitizedPath, File.ModeFlags.Write);
		save_state_to_file(file);
		file.Close();
	}

	public void save_state_to_file(Godot.File file)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		if (file.IsOpen())
		{
			file.StoreString(get_state());
		}
	}

	public void load_state_from_path(string path)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		string sanitizedPath;
		if (!path.StartsWith("res://") && !path.StartsWith("user://"))
		{
			sanitizedPath = $"user://{path}";
		}
		else
		{
			sanitizedPath = path;
		}

		var file = new File();
		file.Open(sanitizedPath, File.ModeFlags.Read);
		load_state_from_file(file);
		file.Close();
	}

	public void load_state_from_file(Godot.File file)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		if (!file.IsOpen()) {
			return;
		}

		file.Seek(0);
		if (file.GetLength() > 0) {
			story.state.LoadJson(file.GetAsText());
		}
	}
	#endregion

	#region Methods | Variables
	public object get_variable(string name)
	{
		if (story == null)
		{
			PushNullStoryError();
			return null;
		}

		try
		{
			var variable = story.variablesState[name];

			if (variable is Ink.Runtime.InkList inkList)
			{
				return inkBridger.MakeGDInkList(inkList);
			}

			if (variable is Ink.Runtime.Path3D path)
			{
				return inkBridger.MakeGDInkPath(path);
			}
		}
		catch(Exception e)
		{
			HandleException(e);
			return null;
		}

		return story.variablesState[name];
	}

	public void set_variable(string name, object value)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			if (value is Godot.Object gdObject)
			{
				if (IsInkObject(gdObject, "InkList"))
				{
					story.variablesState[name] = inkBridger.MakeSharpInkList(gdObject, story);
					return;
				}

				if (IsInkObject(gdObject, "InkPath"))
				{
					story.variablesState[name] = inkBridger.MakeSharpInkPath(gdObject);
					return;
				}
			}
			story.variablesState[name] = value;
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}
	#endregion

	#region Methods | Variable Observers
	public void observe_variables(Godot.Collections.Array<string> variable_names, Godot.Object gd_object, string method_name)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			foreach(string variable_name in variable_names)
			{
				observe_variable(variable_name, gd_object, method_name);
			}
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}

	public void observe_variable(string variable_name, Godot.Object gd_object, string method_name)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			var instanceId = (int)gd_object.Call("get_instance_id");
			var funcRef = GD.FuncRef(gd_object, method_name);

			var functionReference = new FunctionReference(
				variable_name,
				instanceId,
				method_name,
				(string variableName, object value) => {
					funcRef.CallFuncv(new Godot.Collections.Array() { variableName, value });
				}
			);

			if (observers.TryGetValue(variable_name, out List<FunctionReference> referenceList))
			{
				referenceList.Append(functionReference);
			}
			else
			{
				observers[variable_name] = new List<FunctionReference> () { functionReference };
			}

			story.ObserveVariable(variable_name, functionReference.observer);
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}

	public void remove_variable_observer(Godot.Object gd_object, string method_name, string specific_variable_name)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			if (observers.TryGetValue(specific_variable_name, out List<FunctionReference> references))
			{
				var validReferences =
					references.FindAll(reference => reference.Matches(specific_variable_name, gd_object, method_name));

				foreach(FunctionReference reference in validReferences) {
					story.RemoveVariableObserver(reference.observer, specific_variable_name);
					references.Remove(reference);
				}
			}
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}

	public void remove_variable_observer_for_all_variables(Godot.Object gd_object, string method_name)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			List<string> observersToRemove = new List<string>();

			foreach(KeyValuePair<string, List<FunctionReference>> kv in observers)
			{
				var validReferences =
					kv.Value.FindAll(reference => reference.Matches(gd_object, method_name));

				foreach(FunctionReference reference in validReferences) {
					story.RemoveVariableObserver(reference.observer);
					kv.Value.Remove(reference);
				}
			}
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}

	public void remove_all_variable_observers(string specific_variable_name)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			story.RemoveVariableObserver(null, specific_variable_name);
			observers.Remove(specific_variable_name);
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}
	#endregion

	#region Methods | External Functions
	public void bind_external_function(
		string func_name,
		Godot.Object gd_object,
		string method_name)
	{
		bind_external_function(func_name, gd_object, method_name, false);
	}

	public void bind_external_function(
		string func_name,
		Godot.Object gd_object,
		string method_name,
		bool lookahead_safe)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			story.BindExternalFunctionGeneral(
				func_name,
				(object[] args) => gd_object.Call(method_name, args),
				lookahead_safe);
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}

	public void unbind_external_function(string func_name)
	{
		if (story == null)
		{
			PushNullStoryError();
			return;
		}

		try
		{
			story.UnbindExternalFunction(func_name);
		}
		catch (Exception e)
		{
			HandleException(e);
		}
	}
	#endregion

	#region Methods | Functions

	public bool has_function(string function_name)
	{
		if (story == null)
		{
			PushNullStoryError();
			return false;
		}

		return story.HasFunction(function_name);
	}

	public Godot.Object evaluate_function(string function_name, object[] arguments)
	{
		if (story == null)
		{
			PushNullStoryError();
			return null;
		}

		// Prevents sending `null` to EvaluateFunction, which would
		// otherwise raise an exception.
		object[] sanitizedArguments;
		if (arguments != null) {
			sanitizedArguments = arguments;
		} else {
			sanitizedArguments = new object[] { };
		}

		try
		{
			object returnValue = story.EvaluateFunction(function_name, out string textOutput, sanitizedArguments);
			return inkBridger.MakeFunctionResult(textOutput, returnValue);
		}
		catch (Exception e)
		{
			HandleException(e);
			return null;
		}
	}
	#endregion

	#region Methods | Ink List Creation
	public Godot.Object create_ink_list_with_origin(string single_origin_list_name)
	{
		if (story == null)
		{
			PushNullStoryError();
			return null;
		}

		try
		{
			var inkList = new Ink.Runtime.InkList(single_origin_list_name, story);
			return inkBridger.MakeGDInkList(inkList);
		}
		catch (Exception e)
		{
			HandleException(e);
			return null;
		}
	}

	public Godot.Object create_ink_list_from_item_name(string item_name)
	{
		if (story == null)
		{
			PushNullStoryError();
			return null;
		}

		try
		{
			var inkList = Ink.Runtime.InkList.FromString(item_name, story);
			return inkBridger.MakeGDInkList(inkList);
		}
		catch (Exception e)
		{
			HandleException(e);
			return null;
		}
	}
	#endregion

	#region Event Handlers
	private void onError(string message, Ink.Ink.ErrorType type)
	{
		if (stop_execution_on_error)
		{
			PushStoryError(message, type);
			Debug.Assert(false, "Error encountered, check the debugger tab for more information.");
			return;
		}

		if (GetSignalConnectionList("error_encountered").Count == 0)
		{
			PushStoryError(message, type);
		}
		else
		{
			EmitSignal("error_encountered", new object[] { message, type });
		}
	}

	private void onDidContinue()
	{
		EmitSignal("continued",  new object[] { current_text, current_tags });
	}

	private void onMakeChoice(Ink.Runtime.Choice choice)
	{
		EmitSignal("choice_made",  new object[] { choice });
	}

	private void onEvaluateFunction(string functionName, object[] arguments)
	{
		EmitSignal("function_evaluating", new object[] { functionName, arguments });
	}

	private void onCompleteEvaluateFunction(
		string functionName,
		object[] arguments,
		string textOuput,
		object returnValue)
	{
		var functionResult = inkBridger.MakeFunctionResult(textOuput, returnValue);
		var parameters = new object[] { functionName, arguments, functionResult };

		EmitSignal("function_evaluated", parameters);
	}

	private void onChoosePathString(string path, object[] arguments)
	{
		EmitSignal("path_choosen", new object[] { path, arguments });
	}
	#endregion

	#region Private Methods
	private void CreateStory(string json_story)
	{
		story = new Ink.Runtime.Story(json_story);
	}

	private void AsyncCreateStory(string json_story)
	{
		CreateStory(json_story);
		CallDeferred("AsyncCreationCompleted");
	}

	private void AsyncCreationCompleted()
	{
		thread.WaitToFinish();
		thread = null;

		FinaliseStoryCreation();
	}

	private void FinaliseStoryCreation()
	{
		story.onError += onError;
		story.onDidContinue += onDidContinue;
		story.onMakeChoice += onMakeChoice;
		story.onEvaluateFunction += onEvaluateFunction;
		story.onCompleteEvaluateFunction += onCompleteEvaluateFunction;
		story.onChoosePathString += onChoosePathString;

		EmitSignal("loaded", new object[] { true });
	}

	private bool CurrentPlatformSupportsThreads()
	{
		return OS.GetName() != "HTML5";
	}

	private void PushNullStoryError()
	{
		PushError("The story is 'null', was it loaded properly?");
	}

	private void PushNullStateError(string variable)
	{
		var message =
			$"'{variable}' is 'null', the internal state is corrupted or missing, this is an unrecoverable error.";

		PushError(message);
	}

	private void PushStoryError(string message, Ink.Ink.ErrorType type)
	{
		if (Engine.EditorHint)
		{
			switch (type)
			{
				case Ink.Ink.ErrorType.Error:
					GD.PrintErr(message);
					break;
				case Ink.Ink.ErrorType.Warning:
				case Ink.Ink.ErrorType.Author:
					GD.Print(message);
					break;
			}
		}
		else
		{
			switch (type)
			{
				case Ink.Ink.ErrorType.Error:
					GD.PushError(message);
					break;
				case Ink.Ink.ErrorType.Warning:
				case Ink.Ink.ErrorType.Author:
					GD.PushWarning(message);
					break;
			}
		}
	}

	private void PushError(string message)
	{
		if (Engine.EditorHint)
		{
			GD.PrintErr(message);
			foreach(string line in System.Environment.StackTrace.Split("\n"))
			{
				GD.PrintErr(line);
			}
		}
		else
		{
			GD.PushError(message);
		}
	}

	private bool IsValidResource(Resource resource)
	{

		var properties = resource.GetPropertyList().Cast<Godot.Collections.Dictionary>().ToList();
		return (
			properties.Exists(element => "json".Equals(element["name"])) &&
			resource.Get("json") is string
		);
	}

	private bool IsInkObject(Godot.Object inkObject, string name)
	{
		return inkObject.HasMethod("is_ink_class") && (bool)inkObject.Call("is_ink_class", new object[] { name });
	}

	private void HandleException(Exception e)
	{
		var message = $"{GetExceptionType(e)}: {e.Message}";

		if (ShouldStopExecution(e))
		{
			GD.PrintErr(message);

			foreach(string line in e.StackTrace.Split("\n"))
			{
				GD.PrintErr(line);
			}

			Debug.Assert(false, "Exception encountered, check the output tab for more information.");
		}
		else
		{
			EmitSignal("exception_raised", new object[] { message, e.StackTrace.Split("\n") });
		}
	}

	private bool ShouldStopExecution(Exception e)
	{
		return (
			(e is Ink.Runtime.StoryException && stop_execution_on_error) ||
			(!(e is Ink.Runtime.StoryException) && stop_execution_on_exception)
		);
	}

	private string GetExceptionType(Exception e)
	{
		if (e is Ink.Runtime.StoryException)
		{
			return "STORY EXCEPTION";
		}
		else if (e is System.ArgumentException)
		{
			return "ARGUMENT EXCEPTION";
		}
		else
		{
			return "EXCEPTION";
		}

	}
	#endregion

	#region Private Structures
	private readonly struct FunctionReference
	{
		public readonly string variableName;

		public readonly int objectReferenceId;
		public readonly string methodName;

		public readonly Ink.Runtime.Story.VariableObserver observer;

		public FunctionReference(
			string variableName,
			int objectReferenceId,
			string methodName,
			Ink.Runtime.Story.VariableObserver observer)
		{
			this.variableName = variableName;
			this.objectReferenceId = objectReferenceId;
			this.methodName = methodName;
			this.observer = observer;
		}

		public bool Matches(string variableName, Godot.Object gdObject, string methodName)
		{
			return this.variableName.Equals(variableName) && Matches(gdObject, methodName);
		}

		public bool Matches(Godot.Object gdObject, string methodName)
		{
			return (
				(int)gdObject.Call("get_instance_id") == objectReferenceId &&
				this.methodName.Equals(methodName)
			);
		}

		public override string ToString()
		{
			return $"FunctionReference[{variableName}, {objectReferenceId}, {methodName}]";
		}
	}
	#endregion
}
