@icon("res://addons/twitcher/assets/api-icon.svg")
@tool
extends Twitcher

class_name TwitchAPIGenerator

const suffixes: Array[String] = ["Response", "Body", "Opt"]

const api_output_path = "res://addons/twitcher/generated/"
const twitch_api_header : String = """@tool
extends Twitcher

# CLASS GOT AUTOGENERATED DON'T CHANGE MANUALLY. CHANGES CAN BE OVERWRITTEN EASILY.

## Interaction with the Twitch REST API.
class_name TwitchAPI

static var _log: TwitchLogger = TwitchLogger.new("TwitchAPI")

static var instance: TwitchAPI

## Maximal tries to reauthrorize before giving up the request.
const MAX_AUTH_ERRORS: int = 3

## Called when the API returns unauthenticated mostly cause the accesstoken is expired
signal unauthenticated

## Called when the API returns 403 means there are permissions / scopes missing
signal unauthorized

## To authorize against the Twitch API
@export var token: OAuthToken:
	set(val): 
		token = val
		update_configuration_warnings()
## OAuth settings needed for client information
@export var oauth_setting: OAuthSetting:
	set(val):
		oauth_setting = val
		update_configuration_warnings()
## URI to the Twitch API
@export var api_host: String = "https://api.twitch.tv/helix"

## Client to make HTTP requests
var client: BufferedHTTPClient


func _ready() -> void:
	client = BufferedHTTPClient.new()
	client.name = "ApiClient"
	add_child(client)
	
	
func _enter_tree() -> void:
	if instance == null: instance = self
	
	
func _exit_tree() -> void:
	if instance == self: instance = null
	
	
func _get_configuration_warnings() -> PackedStringArray:
	var result: PackedStringArray = []
	if token == null:
		result.append("Please set a token to use")
	if oauth_setting == null:
		result.append("Please set the correct oauth settings")
	return result
		
		
func request(path: String, method: int, body: Variant = "", content_type: String = "", error_count: int = 0) -> BufferedHTTPClient.ResponseData:
	var header : Dictionary = {
		"Authorization": "Bearer %s" % [await token.get_access_token()],
		"Client-ID": oauth_setting.client_id
	}
	if content_type != "":
		header["Content-Type"] = content_type

	var request_body: String = ""
	if body == null || (body is String && body == ""):
		request_body = ""
	elif body is Object && body.has_method("to_json"):
		request_body = body.to_json()
	else:
		request_body = JSON.stringify(body)

	var req: BufferedHTTPClient.RequestData = client.request(api_host + path, method, header, request_body)
	var res: BufferedHTTPClient.ResponseData = await client.wait_for_request(req)

	# Try to fix Godot TLS Bug
	if res.result == 5:
		return await retry(req, res, path, method, body, content_type, error_count + 1)

	match res.response_code:
		401: # Token expired / or missing permissions
			_log.e("'%s' is unauthorized. It is probably your scopes." % path)
			unauthorized.emit()
		403:
			_log.i("'%s' is unauthenticated. Refresh token." % path)
			unauthenticated.emit()
			await token.authorized
			return await retry(req, res, path, method, body, content_type, error_count + 1)
	return res


func retry(request: BufferedHTTPClient.RequestData,
		response: BufferedHTTPClient.ResponseData,
		path: String,
		method: int,
		body: Variant = "",
		content_type: String = "",
		error_count: int = 0) -> BufferedHTTPClient.ResponseData:
	if error_count + 1 < MAX_AUTH_ERRORS:
		return await request(path, method, body, content_type, error_count + 1)
	else:
		# Give up the request after trying multiple times and
		# return an empty response with correct error code
		var empty_response: BufferedHTTPClient.ResponseData = client.empty_response(request)
		empty_response.response_code = response.response_code
		return empty_response


func _handle_error(method_name: String, response: BufferedHTTPClient.ResponseData) -> void:
	var error_json: String = response.response_data.get_string_from_utf8()
	var error = JSON.parse_string(error_json)
	push_error("Problems while calling %s: " % method_name, error["message"])
	_log.d(error_json)


## Converts unix timestamp to RFC 3339 (example: 2021-10-27T00:00:00Z) when passed a string uses as is
static func get_rfc_3339_date_format(time: Variant) -> String:
	if typeof(time) == TYPE_INT:
		var date_time = Time.get_datetime_dict_from_unix_time(time)
		return "%s-%02d-%02dT%02d:%02d:%02dZ" % [date_time['year'], date_time['month'], date_time['day'], date_time['hour'], date_time['minute'], date_time['second']]
	return str(time)

"""

@export var parser: TwitchAPIParser

var grouped_files: Dictionary[String, Variant] = {}


func prepare_component(component: TwitchGenComponent) -> void:
	if component._is_root:
		var base_name = get_base_name(component._classname)
		
		# No suffix class lives by its own
		if base_name == component._classname:
			if grouped_files.has(base_name):
				push_error("That file shouldn't exist: %s" % base_name)
			component._classname = "Twitch" + component._classname
			grouped_files[base_name] = component
		else:
			var file: GroupedComponent = grouped_files.get(base_name, GroupedComponent.new())
			file.base_name = "Twitch" + base_name
			file.components.append(component)
			grouped_files[base_name] = file
			component._classname = component._classname.trim_prefix(base_name)
			component.set_meta("fqdn", file.base_name + "." + component._classname)
			var sub_components_to_update: Array[TwitchGenComponent] = component._sub_components.values().duplicate()
			for sub_component in sub_components_to_update:
				sub_component._classname = component._classname + sub_component._classname
				sub_components_to_update.append_array(sub_component._sub_components.values())
	pass
	
	
	
func generate_api() -> void:
	for component: Variant in parser.components:
		prepare_component(component)
	
	# Generate TwitchAPI
	var twitch_api_code = twitch_api_header
	for method: TwitchGenMethod in parser.methods:
		twitch_api_code += method_code(method)
	write_output_file(api_output_path + "twitch_api.gd", twitch_api_code)
	
	# Generate Components
	for component: Variant in grouped_files.values():
		var code = ""
		if component is GroupedComponent:
			code = group_code(component)
		else:
			code = component_code(component, 0)
		write_output_file(api_output_path + component.get_filename(), code)
	
	print("API regenerated you can find it under: %s" % api_output_path)


class GroupedComponent extends RefCounted:
	var base_name: String
	var prefix: String
	var components: Array[TwitchGenComponent] = []
	
	
	func _update_base_name(val: String) -> void:
		base_name = val


	func get_filename() -> String:
		return base_name.to_snake_case() + ".gd"


func group_code(group: GroupedComponent) -> String:
	var code = """@tool
extends TwitchData

# CLASS GOT AUTOGENERATED DON'T CHANGE MANUALLY. CHANGES CAN BE OVERWRITTEN EASILY.

class_name {name}
	""".format({"name": group.base_name})
	for component in group.components:
		component._is_root = false
		code += "\n\n"
		code += component_code(component, 1)
	return code
	
#region Field Code Generation

func field_declaration(field: TwitchGenField) -> String:
	var type = get_type(field._type, field._is_array)
	return """
## {description}
@export var {name}: {type}:
	set(val): 
		{name} = val
		track_data(&"{name}", val)\n""".format({
			"name": field._name,
			"description": ident(field._description, 0, "## "),
			"type": type
		})


#endregion

#region Parameter Code Generation

#func get_code() -> String:
	#if _name == "broadcaster_id":
		#var default_value = "default_broadcaster_login" if _type == "String" else "[default_broadcaster_login]"
		#return "%s: %s = %s" % [_name, get_type(), default_value]
	#return "%s: %s" % [_name, get_type()]

#endregion

#region Method Code Generation

func parameter_doc(method: TwitchGenMethod) -> String:
	if method._required_parameters.is_empty():
		return "## [no required query parameters to describe]"
	var doc : String = ""
	for parameter: TwitchGenParameter in method._required_parameters:
		doc += "## {name} - {documentation} \n".format({
			'name': parameter._name,
			'documentation': ident(parameter._description, 0, "##    ")
		})
	return doc.rstrip("\n")


func parameter_array(method: TwitchGenMethod, with_type: bool = false, fully_qualified: bool = false) -> Array[String]:
	var parameters : Array[String] = []
	if method._contains_body: parameters.append(get_parameter("body", method._body_type, false, with_type, fully_qualified))
	if method._contains_optional: parameters.append(get_parameter("opt", method.get_optional_type(), false, with_type, fully_qualified))
				
	method._parameters.sort_custom(TwitchGenParameter.sort)
	for parameter: TwitchGenParameter in method._required_parameters:
		parameters.append(get_parameter(parameter._name, parameter._type, parameter._is_array, with_type, fully_qualified))
	return parameters


func method_parameter(method: TwitchGenMethod, with_type: bool = false, fully_qualified: bool = false) -> String:
	return ", ".join(parameter_array(method, with_type, fully_qualified))
	
	
func path_code(method: TwitchGenMethod) -> String:
	var body_code : String = "var path = \"%s?\"\n" % method._path
	
	if method._contains_optional:
		body_code += "var optionals: Dictionary[StringName, Variant] = {}\n"
		body_code += "if opt != null: optionals = opt.to_dict()\n"
		
	for parameter: TwitchGenParameter in method._parameters:
		if parameter._required:
			body_code += parameter_path_code(parameter) + "\n"
		else:
			body_code += "if optionals.has(\"%s\"):\n" % parameter._name
			body_code += "\t%s\n" % ident(parameter_path_code(parameter, "optionals."), 1)
	return body_code
	
	
func parameter_path_code(parameter: TwitchGenParameter, prefix: String = "") -> String:
	var body: String
	if parameter._is_time:
		body = "path += \"{key}=\" + get_rfc_3339_date_format({value}) + \"&\""
			
	elif parameter._is_array:
		body = """
for param in {value}:
	path += "{key}=" + str(param) + "&" """.trim_prefix("\n\t")
	else:
		body = "path += \"{key}=\" + str({value}) + \"&\""
				
	return body.format({ 
		'value': prefix + parameter._name,
		'key': parameter._name 
	})

	## Exceptional method cause twitch api is not uniform
func paging_code_stream_schedule() -> String:
	return """
if parsed_result.data.pagination != null:
	opt.after = parsed_result.data.pagination.cursor
	parsed_result.data._next_page = get_channel_stream_schedule.bind(opt, broadcaster_id)\n"""


func paging_code(method: TwitchGenMethod) -> String:
	if method._name == "get_channel_stream_schedule":
		return paging_code_stream_schedule()
		
	var code: String = ""
	code += "if parsed_result.pagination != null:\n"
	var after_parameter: TwitchGenParameter = method.get_parameter_by_name("after")
	var result_component: TwitchGenComponent = get_component(method._result_type)
	var pagination_parameter: TwitchGenField = result_component.get_field_by_name("pagination")
	if pagination_parameter == null:
		print("Check %s paging without paging?" % method._name)
		pass
	elif pagination_parameter._type == "String":
		code += "\tvar cursor: String = parsed_result.pagination\n"
	else:
		code += "\tvar cursor: String = parsed_result.pagination.cursor\n"
	if after_parameter._required:
		code += "\t{parameter} = cursor\n"
	else:
		code += "\tif not opt: opt = {opt_type}.new()\n"
		code += "\topt.{parameter} = cursor\n"
	code += "\tparsed_result._next_page = {name}.bind({parameters})\n"
	
	var opt_parameter: String = get_type(method.get_optional_type(), false, true)
	return code.format({
		"parameter": after_parameter._name,
		"name": method._name,
		"parameters": method_parameter(method),
		"opt_type": opt_parameter
	})


func method_code(method: TwitchGenMethod) -> String:
	print("GENERATE METHOD ", method._name)
	
	var result_type = get_type(method._result_type, false, true)

	var template = """

## {summary}
## 
{parameter_doc}
##
## {doc_url}
func {name}({parameters}) -> {result_type}:
	{path_code}
	var response: BufferedHTTPClient.ResponseData = await request(path, HTTPClient.METHOD_{method}, {body_variable}, "{content_type}")
"""

	if result_type == "BufferedHTTPClient.ResponseData":
		template += """
	if response.response_code >= 400: 
		_handle_error("{name}", response)
	return response
"""
	else:
		template += """
	var result: Variant = {}
	if response.response_code >= 400: 
		_handle_error("{name}", response)
	else:
		result = JSON.parse_string(response.response_data.get_string_from_utf8())
		
	var parsed_result: {result_type} = {result_type}.from_json(result)
	parsed_result.response = response
	{paging_code}
	return parsed_result
"""	
	
	return template.format({
			"summary": method._summary,
			"parameter_doc": parameter_doc(method),
			"doc_url": method._doc_url,
			"name": method._name,
			"parameters": method_parameter(method, true, true),
			"result_type": get_type(method._result_type, false, true),
			"path_code": ident(path_code(method), 1),
			"content_type": get_type(method._content_type, false, true),
			"method": method._http_verb.to_upper(),
			"body_variable": "body" if method._contains_body else "\"\"",
			"paging_code": ident(paging_code(method), 1) if method._has_paging else ""
		})


#endregion

#region Component Code Generation


func component_code(component: TwitchGenComponent, level: int = 0) -> String:
	var code: String = ""
	if component._is_root:
		code +=  """@tool
extends TwitchData

# CLASS GOT AUTOGENERATED DON'T CHANGE MANUALLY. CHANGES CAN BE OVERWRITTEN EASILY.

## {description}
## {ref}
class_name {classname}
	"""
	else:
		code = """
## {description}
## {ref}
class {classname} extends TwitchData:
"""
	var class_code : String = ""
	for field: TwitchGenField in component._fields:
		class_code += field_declaration(field)
		
	if component._is_response:
		class_code += "var response: BufferedHTTPClient.ResponseData"
	class_code += "\n\n"
	class_code += create_code(component) + "\n\n"
	class_code += from_json_code(component)
	
	if component._has_paging:
		class_code += "\n\n" + iter_code(component)
		
	var sub_component_code: String
	for sub_component in component._sub_components.values():
		sub_component_code += "\n\n" + component_code(sub_component, 1)
	
	return code.format({
		"description": ident(component._description, 0, "## "),
		"classname": component._classname,
		"ref": component._ref
	}) + ident(class_code, level) + sub_component_code
	
	
func create_code(component: TwitchGenComponent) -> String:
	var parameters: Array[String] = []
	for field in component._fields:
		if field._is_required:
			parameters.append("_" + get_parameter(field._name, field._type, field._is_array))
	
	var variable_name = component._classname.to_snake_case()
	var code : String = """
## Constructor with all required fields.
static func create({parameters}) -> {classname}:
	var {variablename}: {classname} = {classname}.new()\n""".format({
			"parameters": ", ".join(parameters),
			"classname": component._classname,
			"variablename": variable_name
		})
	
	for field in component._fields:
		if field._is_required:
			code += "\t{classname}.{name} = _{name}\n".format({
				"name": field._name,
				"classname": variable_name
			})

	code += "\treturn %s" % variable_name
	return code
		
	
func from_json_code(component: TwitchGenComponent) -> String:
	var code : String = """
static func from_json(d: Dictionary) -> {classname}:
	var result: {classname} = {classname}.new()
""".format({"classname": component._classname})
	for field: TwitchGenField in component._fields:
		code += "\tif d.get(\"{name}\", null) != null:\n"
		if field._is_typed_array:
			code += """
		for value in d["{name}"]:
			result.{name}.append({type}.from_json(value))\n""".lstrip("\n")
		elif field._is_array:
			code += """
		for value in d["{name}"]:
			result.{name}.append(value)\n""".lstrip("\n")
		elif field._is_sub_class:
			code += "\t\tresult.{name} = {type}.from_json(d[\"{name}\"])\n"
		else:
			code += "\t\tresult.{name} = d[\"{name}\"]\n"
		code = code.format({
			"name": field._name,
			"type": get_type(field._type, false)
		})
	code += "\treturn result\n"
	
	return code
		
		
func iter_code(component: TwitchGenComponent) -> String:
	var data_variable_name: String = "data"
	var path_to_data: String = ""
	if component._ref == "#/components/schemas/GetChannelStreamScheduleResponse/Data":
		data_variable_name = "segments"
		path_to_data = "data."
		
	var code: String
	if component._ref == "#/components/schemas/GetExtensionLiveChannelsResponse":
		code += """
func _has_pagination() -> bool:
	if pagination == null || pagination == "": return false
	return true
"""
	else:
		code += """
func _has_pagination() -> bool:
	if pagination == null: return false
	if pagination.cursor == null || pagination.cursor == "": return false
	return true
"""
		
	code += """
var _next_page: Callable
var _cur_iter: int = 0


func next_page() -> {response_type}:
	var response: {response_type} = await _next_page.call()
	_cur_iter = 0
	_next_page = response.{path_to_data}_next_page
{copy_code}
	return response


func _iter_init(iter: Array) -> bool:
	if {data_variable_name}.is_empty(): return false
	iter[0] = {data_variable_name}[0]
	_cur_iter = 1
	return true
	
	
func _iter_next(iter: Array) -> bool:
	if {data_variable_name}.size() > _cur_iter:
		iter[0] = {data_variable_name}[_cur_iter]
		_cur_iter += 1
	elif not _has_pagination(): 
		return false
	return true
	
	
func _iter_get(iter: Variant) -> Variant:
	if {data_variable_name}.size() - 1 == _cur_iter && _has_pagination():
		await next_page()
	return iter"""
	var copy_code: String
	for field in component._fields:
		copy_code += "\t{_name} = response.{path_to_data}{_name}\n".format(field)

	return code.format({
		"data_variable_name": data_variable_name,
		"copy_code": copy_code,
		"path_to_data": path_to_data,
		"response_type": component.get_root_classname()
	})
#endregion

#region Utils


func get_type(type: String, is_array: bool = false, full_qualified: bool = false) -> String:
	var result_type : String = ""
	if type.begins_with("#"):
		var component: TwitchGenComponent = parser.get_component_by_ref(type)
		result_type = component._classname
		if full_qualified and component.has_meta("fqdn"):
			result_type = component.get_meta("fqdn")
	else:
		result_type = type
	return result_type if not is_array else "Array[%s]" % result_type


func get_component(type: String) -> TwitchGenComponent:
	if type.begins_with("#"):
		return parser.get_component_by_ref(type)
	else:
		return null

func ident(code: String, level: int, padding: String = "") -> String:
	return code.replace("\n", "\n" + "\t".repeat(level) + padding)


# Writes the processed content to the output file.
func write_output_file(file_output: String, content: String) -> void:
	var file = FileAccess.open(file_output, FileAccess.WRITE);
	if file == null:
		var error_message = error_string(FileAccess.get_open_error());
		push_error("Failed to open output file: %s\n%s" % [file_output, error_message])
		return
	file.store_string(content)
	file.flush()
	file.close()
	
	
func get_base_name(file: String) -> String:
	var new_file: String = file
	for suffix: String in suffixes:
		new_file = new_file.trim_suffix(suffix)
	return new_file
	
	
func get_parameter(title: String, type: String, is_array = false, with_type: bool = true, fully_qualified: bool = false) -> String:
	if with_type:
		return "{name}: {type}".format({
			"name": title,
			"type": get_type(type, is_array, fully_qualified)
		})
	else:
		return title
		
#endregion
