@tool
extends EditorPlugin


#------------------------------- SETUP -------------------------------
# Utilities
var Utils = preload("res://addons/godot_super-wakatime/utils.gd").new()
var DecompressorUtils = preload("res://addons/godot_super-wakatime/decompressor.gd").new()

# Paths, Urls
const PLUGIN_PATH: String = "res://addons/godot_super-wakatime"
const ZIP_PATH: String = "%s/wakatime.zip" % PLUGIN_PATH

const WAKATIME_URL_FMT: String = \
	"https://github.com/wakatime/wakatime-cli/releases/download/v1.54.0/{wakatime_build}.zip"
const DECOMPERSSOR_URL_FMT: String = \
	"https://github.com/ouch-org/ouch/releases/download/0.3.1/{ouch_build}"

# Names for menu
const API_MENU_ITEM: String = "Wakatime API key"
const CONFIG_MENU_ITEM: String = "Wakatime Config File"

# Directories to grab wakatime from
var wakatime_dir = null
var wakatime_cli = null
var decompressor_cli = null

var ApiKeyPrompt: PackedScene = preload("res://addons/godot_super-wakatime/api_key_prompt.tscn")
var Counter: PackedScene = preload("res://addons/godot_super-wakatime/counter.tscn")

# Set platform
var system_platform: String = Utils.set_platform()[0]
var system_architecture: String = Utils.set_platform()[1]

var debug: bool = false

# parity with kate-wakatime
const LOG_INTERVAL: int = 120000

var key_get_tries: int = 0
var counter_instance: Node
var current_time: String = "0 hrs, 0mins"


# #------------------------------- DIRECT PLUGIN FUNCTIONS -------------------------------
func _ready() -> void:
	setup_plugin()
	set_process(true)
	
func _exit_tree() -> void:
	_disable_plugin()
	set_process(false)
	
	
class DataSnapshot:
	var file_path: String
	var line_no: int 
	var cursor_pos: int 
	var lines: int 


func get_coding_data(file: Script = null) ->  DataSnapshot:
	var snapshot = DataSnapshot.new()
	if not file:
		file = get_editor_interface().get_script_editor().get_current_script()

	if not file:
		return null

	snapshot.file_path = ProjectSettings.globalize_path(file.resource_path)

	var code_edit: CodeEdit = get_editor_interface().get_script_editor().get_current_editor().get_base_editor()
	if code_edit is not CodeEdit:
		return null
	snapshot.line_no = code_edit.get_caret_line()
	snapshot.cursor_pos = code_edit.get_caret_column()
	snapshot.lines = code_edit.get_line_count()

	return snapshot

func get_building_data(mouse_event: InputEventMouse, file_path: String = "") -> DataSnapshot:
	"""Generate"""
	var snapshot = DataSnapshot.new()
	if file_path == "":
		if EditorInterface.get_edited_scene_root() == null:
			return null
		var file = EditorInterface.get_edited_scene_root().scene_file_path

		if not file:
			return null

		snapshot.file_path = ProjectSettings.globalize_path(file)
	else:
		snapshot.file_path = file_path

	snapshot.line_no = int(roundf(mouse_event.position.x))
	snapshot.cursor_pos = int(roundf(mouse_event.position.y))

	# no line numbers in a screen lol
	snapshot.lines = 0
	return snapshot

	
var last_tick_frame: int = 0
var last_mouse_event: InputEventMouse
var moved_on_edit: bool = false
func _input(event: InputEvent) -> void:
	"""Handle all input events"""

	if Time.get_ticks_msec() - last_tick_frame > LOG_INTERVAL:

		if tab_open == "Script":
			if event is InputEventKey:
				var key_event = event as InputEventKey
				if (key_event.keycode == KEY_UP   || key_event.keycode == KEY_DOWN 
				 || key_event.keycode == KEY_LEFT || key_event.keycode == KEY_RIGHT 
				 || key_event.keycode == KEY_ALT  || key_event.keycode == KEY_SHIFT):
					return
  
				var snapshot = get_coding_data()

				if snapshot == null:
					return

				send_heartbeat(snapshot.file_path, "coding",  snapshot.line_no, snapshot.cursor_pos, snapshot.lines, false)

				last_tick_frame = Time.get_ticks_msec()

		elif tab_open == "2D" || tab_open == "3D":
			if event is InputEventMouse: # we dont really care if people are typing in node editor
				var snapshot = get_building_data(event)

				if snapshot == null:
					return

				send_heartbeat(snapshot.file_path, "building",  snapshot.line_no, snapshot.cursor_pos, snapshot.lines, false)

				last_tick_frame = Time.get_ticks_msec()
 
	# store the mouse data for scene saved
	if tab_open == "2D" || tab_open == "3D":
		if event is InputEventMouse:
			last_mouse_event = event
			moved_on_edit = true
   

# 2D, 3D, Script, Game, or AssetLib
var tab_open: String = "2D"
func _main_screen_changed(tab_name: String):
	"""Update the curent tab so we can know where our input events are going to"""
	tab_open = tab_name


# no cooldown for writes
func _scene_saved(file_path: String):
	"""Handle heartbeats to be sent on scene save"""
	if last_mouse_event == null:
		return

	if !moved_on_edit:
		return

	moved_on_edit = false

	var snapshot = get_building_data(last_mouse_event)

	if snapshot == null:
		return

	send_heartbeat(snapshot.file_path, "building",  snapshot.line_no, snapshot.cursor_pos, snapshot.lines, true)
 
func _resource_saved(resource: Resource):
	"""Handle heartbeats to be sent on file save"""
	if not resource is GDScript:
		return

	var snapshot = get_coding_data(resource)
 
	if snapshot == null:
		return

	send_heartbeat(snapshot.file_path, "coding",  snapshot.line_no, snapshot.cursor_pos, snapshot.lines, true)


func setup_plugin() -> void:
	"""Setup Wakatime plugin, download dependencies if needed, initialize menu"""
	Utils.plugin_print("Setting up %s" % get_user_agent())
	check_dependencies()

	main_screen_changed.connect(_main_screen_changed)
	scene_saved.connect(_scene_saved)
	resource_saved.connect(_resource_saved)
	 
	# Grab API key if needed
	var api_key = get_api_key()
	if api_key == null:
		request_api_key()
	await get_tree().process_frame
	
	# Add menu buttons
	add_tool_menu_item(API_MENU_ITEM, request_api_key)
	add_tool_menu_item(CONFIG_MENU_ITEM, open_config)
	
	counter_instance = Counter.instantiate()
	add_control_to_bottom_panel(counter_instance, current_time)

func _disable_plugin() -> void:
	"""Cleanup after disabling plugin"""
	# Remove items from menu
	remove_tool_menu_item(API_MENU_ITEM)
	remove_tool_menu_item(CONFIG_MENU_ITEM)
	
	remove_control_from_bottom_panel(counter_instance)
	

func send_heartbeat(filepath: String, catagory: String, line_num: int, cursor_pos: int, lines: int, is_write: bool) -> void:
	"""Send Wakatimde heartbeat for the specified file"""

	if not Utils.wakatime_cli_exists(get_waka_cli()):
		print("Wakatime not installed!")
		return

	# Check Wakatime API key
	var api_key = get_api_key()
	if api_key == null:
		Utils.plugin_print("Failed to get Wakatime API key. Are you sure it's correct?")
		if (key_get_tries < 3):
			request_api_key()
			key_get_tries += 1
		else:
			Utils.plugin_print("If this keep occuring, please create a file: ~/.wakatime.cfg\n
				initialize it with:\n[settings]\napi_key={your_key}")
		return
		
	# Append all informations as Wakatime CLI arguments
	var cmd: Array[Variant] = ["--entity", filepath, "--key", api_key]
	if is_write:
		cmd.append("--write")
	cmd.append_array(["--alternate-project", ProjectSettings.get("application/config/name")])
	cmd.append_array(["--time", str(Time.get_unix_time_from_system())])
	cmd.append_array(["--lineno", str(line_num)])
	cmd.append_array(["--cursorpos", str(cursor_pos)])
	cmd.append_array(["--lines-in-file", str(lines)])
	cmd.append_array(["--plugin", get_user_agent()])
	
	cmd.append_array(["--category", catagory])

	# Send heartbeat using Wakatime CLI
	var cmd_callable = Callable(self, "_handle_heartbeat").bind(cmd)

	WorkerThreadPool.add_task(cmd_callable)
	
func _find_code_edit_recursive(node: Node) -> CodeEdit:
	"""Find recursively code editor in a node"""
	# If node is already a code editor, return it
	if node is CodeEdit:
		return node

	# Try to find it in every child of a given node		
	for child in node.get_children():
		var editor = _find_code_edit_recursive(child)
		if editor:
			return editor
	return null
	
func _handle_heartbeat(cmd_arguments) -> void:
	"""Handle sending the heartbeat"""
	# Get Wakatime CLI and try to send a heartbeat
	if wakatime_cli == null:
		wakatime_cli = Utils.get_waka_cli()
		
	var output: Array[Variant] = []
	var exit_code: int = OS.execute(wakatime_cli, cmd_arguments, output, true)
	
	update_today_time(wakatime_cli)
		
	# Inform about success or errors if user is in debug
	if debug:
		if exit_code == -1:
			Utils.plugin_print("Failed to send heartbeat: %s" % output)
		else:
			Utils.plugin_print("Heartbeat sent: %s" % output)
			
func update_today_time(wakatime_cli) -> void:
	"""Update today's time in menu"""
	var output: Array[Variant] = []
	# Get today's time from Wakatime CLI
	var exit_code: int = OS.execute(wakatime_cli, ["--today"], output, true)
	
	# Convert it and combine different categories into
	if exit_code == 0:
		current_time = output[0]
	else:
		current_time = "Wakatime"
	#print(current_time)
	call_deferred("_update_panel_label", current_time, output[0])
	
func _update_panel_label(label: String, content: String):
	"""Update bottom panel name that shows time"""
	# If counter exists and it has a label, update both the label and panel's name
	if counter_instance and counter_instance.get_node("HBoxContainer/Label"):
		counter_instance.get_node("HBoxContainer/Label").text = content
		# Workaround to rename panel
		remove_control_from_bottom_panel(counter_instance)
		add_control_to_bottom_panel(counter_instance, label)
		

#------------------------------- FILE FUNCTIONS -------------------------------
func open_config() -> void:
	"""Open wakatime config file"""
	OS.shell_open(Utils.config_filepath(system_platform, PLUGIN_PATH))
	
func get_waka_dir() -> String:
	"""Search for and return wakatime directory"""
	if wakatime_dir == null:
		wakatime_dir = "%s/.wakatime" % Utils.home_directory(system_platform, PLUGIN_PATH)
	return wakatime_dir
	
func get_waka_cli() -> String:
	"""Get wakatime_cli file"""
	if wakatime_cli == null:
		var build = Utils.get_waka_build(system_platform, system_architecture)
		var ext: String = ".exe" if system_platform == "windows" else ''
		wakatime_cli = "%s/%s%s" % [get_waka_dir(), build, ext]
	return wakatime_cli
	
func check_dependencies() -> void:
	"""Make sure all dependencies exist"""
	if !Utils.wakatime_cli_exists(get_waka_cli()):
		download_wakatime()
		if !DecompressorUtils.lib_exists(decompressor_cli, system_platform, PLUGIN_PATH):
			download_decompressor()
	
func download_wakatime() -> void:
	"""Download wakatime cli"""
	Utils.plugin_print("Downloading Wakatime CLI...")
	var url: String = WAKATIME_URL_FMT.format({"wakatime_build": 
			Utils.get_waka_build(system_platform, system_architecture)})
	
	# Try downloading wakatime
	var http = HTTPRequest.new()
	http.download_file = ZIP_PATH
	http.connect("request_completed", Callable(self, "_wakatime_download_completed"))
	add_child(http)
	
	# Handle errors
	var status = http.request(url)
	if status != OK:
		Utils.plugin_print_err("Failed to start downloading Wakatime [Error: %s]" % status)
		_disable_plugin()
	
func download_decompressor() -> void:
	"""Download ouch decompressor"""
	Utils.plugin_print("Downloading Ouch! decompression library...")
	var url: String = DECOMPERSSOR_URL_FMT.format({"ouch_build": 
			Utils.get_ouch_build(system_platform)})
	if system_platform == "windows":
		url += ".exe"
		
	# Try to download ouch
	var http = HTTPRequest.new()
	http.download_file = DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH)
	http.connect("request_completed", Callable(self, "_decompressor_download_finished"))
	add_child(http)
	
	# Handle errors
	var status = http.request(url)
	if status != OK:
		_disable_plugin()
		Utils.plugin_print_err("Failed to start downloading Ouch! library [Error: %s]" % status)
		
func _wakatime_download_completed(result, status, headers, body) -> void:
	"""Finish downloading wakatime, handle errors"""
	if result != HTTPRequest.RESULT_SUCCESS:
		Utils.plugin_print_err("Error while downloading Wakatime CLI")
		_disable_plugin()
		return
	
	Utils.plugin_print("Wakatime CLI has been installed succesfully! Located at %s" % ZIP_PATH)
	extract_files(ZIP_PATH, get_waka_dir())
	
func _decompressor_download_finished(result, status, headers, body) -> void:
	"""Handle errors and finishi decompressor download"""
	# Error while downloading
	if result != HTTPRequest.RESULT_SUCCESS:
		Utils.plugin_print_err("Error while downloading Ouch! library")
		_disable_plugin()
		return
	
	# Error while saving
	if !DecompressorUtils.lib_exists(decompressor_cli, system_platform, PLUGIN_PATH):
		Utils.plugin_print_err("Failed to save Ouch! library")
		_disable_plugin()
		return

	# Save decompressor path, give write permissions to it
	var decompressor: String = \
		ProjectSettings.globalize_path(DecompressorUtils.decompressor_cli(decompressor_cli, 
			system_platform, PLUGIN_PATH))
			
	if system_platform == "linux" or system_platform == "darwin":
		OS.execute("chmod", ["+x", decompressor], [], true)
		
	# Extract files, allowing usage of Ouch!
	Utils.plugin_print("Ouch! has been installed succesfully! Located at %s" % \
		DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH))
	extract_files(ZIP_PATH, get_waka_dir())
	
func extract_files(source: String, destination: String) -> void:
	"""Extract downloaded Wakatime zip"""
	# If decompression library and wakatime zip folder don't exist, return
	if not DecompressorUtils.lib_exists(decompressor_cli, system_platform, 
			PLUGIN_PATH) and not Utils.wakatime_zip_exists(ZIP_PATH):
		return
		
	# Get paths as global
	Utils.plugin_print("Extracting Wakatime...")
	var decompressor: String
	if system_platform == "windows":
		decompressor = ProjectSettings.globalize_path( 
			DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH))
	else:
		decompressor = ProjectSettings.globalize_path("res://" +
			DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH))
		
	var src: String = ProjectSettings.globalize_path(source)
	var dest: String = ProjectSettings.globalize_path(destination)
	
	# Execute Ouch! decompression command, catch errors
	var errors: Array[Variant] = []
	var args: Array[String] = ["--yes", "decompress", src, "--dir", dest]
	
	var error: int = OS.execute(decompressor, args, errors, true)
	if error:
		Utils.plugin_print(errors)
		_disable_plugin()
		return
		
	# Results
	if Utils.wakatime_cli_exists(get_waka_cli()):
		Utils.plugin_print("Wakatime CLI installed (path: %s)" % get_waka_cli())
	else:
		Utils.plugin_print_err("Installation of Wakatime failed")
		_disable_plugin()
		
	# Remove leftover files
	clean_files()
	
func clean_files():
	"""Delete files that aren't needed anymore"""
	if Utils.wakatime_zip_exists(ZIP_PATH):
		delete_file(ZIP_PATH)
	if DecompressorUtils.lib_exists(decompressor_cli, system_platform, PLUGIN_PATH):
		delete_file(ZIP_PATH)
		
func delete_file(path: String) -> void:
	"""Delete file at specified path"""
	var dir: DirAccess = DirAccess.open("res://")
	var status: int = dir.remove(path)
	if status != OK:
		Utils.plugin_print_err("Failed to clean unnecesary file at %s" % path)
	else:
		Utils.plugin_print("Clean unncecesary file at %s" % path)

#------------------------------- API KEY FUNCTIONS -------------------------------

func get_api_key():
	"""Get wakatime api key"""
	var result = []
	# Handle errors while getting the key
	var err = OS.execute(get_waka_cli(), ["--config-read", "api_key"], result)
	if err == -1:
		return null
		
	# Trim API key from whitespaces and return it
	var key = result[0].strip_edges()
	if key.is_empty():
		return null
	return key
	
func request_api_key() -> void:
	"""Request Wakatime API key from the user"""
	# Prepare prompt
	var prompt = ApiKeyPrompt.instantiate()
	_set_api_key(prompt, get_api_key())
	_register_api_key_signals(prompt)
	
	# Show prompt and hide it on request
	add_child(prompt)
	prompt.popup_centered()
	await prompt.popup_hide
	prompt.queue_free()
	
func _set_api_key(prompt: PopupPanel, api_key) -> void:
	"""Set API key from prompt"""
	# Safeguard against empty key
	if api_key == null:
		api_key = ''
		
	# Set correct text, to show API key
	var edit_field: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/LineEdit")
	edit_field.text = api_key
	
func _register_api_key_signals(prompt: PopupPanel) -> void:
	"""Connect all signals related to API key popup"""
	# Get all Nodes
	var show_button: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/ShowButton")
	var save_button: Node = prompt.get_node("VBoxContainer/HBoxContainerBottom/SubmitButton")
	
	# Connect them to press events
	show_button.connect("pressed", Callable(self, "_on_toggle_key_text").bind(prompt))
	save_button.connect("pressed", Callable(self, "_on_save_key").bind(prompt))
	prompt.connect("popup_hide", Callable(self, "_on_popup_hide").bind(prompt))
	
func _on_popup_hide(prompt: PopupPanel):
	"""Close the popup window when user wants to hide it"""
	prompt.queue_free()
	
func _on_toggle_key_text(prompt: PopupPanel) -> void:
	"""Handle hiding and showing API key"""
	# Get nodes
	var show_button: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/ShowButton")
	var edit_field: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/LineEdit")
	
	# Set the correct text and hide field if needed
	edit_field.secret = not edit_field.secret
	show_button.text = "Show" if edit_field.secret else "Hide"
	
func _on_save_key(prompt: PopupPanel) -> void:
	"""Handle entering API key"""
	# Get text field node and api key that's entered
	var edit_field: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/LineEdit")
	var api_key  = edit_field.text.strip_edges()
	
	# Try to set api key for wakatime and handle errors
	var err: int = OS.execute(get_waka_cli(), ["--config-write", "api-key=%s" % api_key])
	if err == -1:
		Utils.plugin_print("Failed to save API key")
	prompt.visible = false
	
#------------------------------- PLUGIN INFORMATIONS -------------------------------
	
func get_user_agent() -> String:
	"""Get user agent identifier"""
	var os_name = OS.get_name().to_lower()
	return "godot/%s %s/%s" % [
		get_engine_version(), 
		_get_plugin_name(),
		_get_plugin_version()
	]
	 
func _get_plugin_name() -> String:
	"""Get name of the plugin"""
	return "Godot_Super-Wakatime"
	
func _get_plugin_version() -> String:
	"""Get version of the plugin"""
	if get_plugin_version() == "":
		return "unknown"

	return get_plugin_version()
	
func get_engine_version() -> String:
	"""Get verison of currently used engine"""
	return "%s.%s.%s" % [Engine.get_version_info()["major"], Engine.get_version_info()["minor"], 
		Engine.get_version_info()["patch"]]
