@icon("./security-icon.svg")
@tool
extends Node

class_name OAuthTokenHandler

const OAuthHTTPClient = preload("res://addons/twitcher/lib/http/buffered_http_client.gd")
const OAuthDeviceCodeResponse = preload("./oauth_device_code_response.gd")

## Handles refreshing and resolving access and refresh tokens.

const HEADERS = {
	"Accept": "*/*",
	"Content-Type": "application/x-www-form-urlencoded"
}
const SECONDS_TO_CHECK_EARLIER = 60

## Called when new access token is available
signal token_resolved(tokens: OAuthToken)

## Called when token can't be refreshed cause auth was removed or refresh token expired
signal unauthenticated()

## Where to get the tokens from
@export var oauth_setting: OAuthSetting

## Holds the current set of tokens
@export var token: OAuthToken: set = _update_token

## Client to request new tokens
var _http_client : OAuthHTTPClient

## Is currently requesting tokens
var _requesting_token: bool = false

## Timer to refresh tokens
var _expiration_check_timer: Timer

func _ready() -> void:
	_http_client = OAuthHTTPClient.new()
	_http_client.name = "OAuthTokenClient"
	add_child(_http_client)
	
	_expiration_check_timer = Timer.new()
	_expiration_check_timer.name = "ExpirationCheck"
	_expiration_check_timer.timeout.connect(refresh_tokens)
	add_child(_expiration_check_timer)
	update_expiration_check()
	
	
func _enter_tree() -> void:
	if not is_instance_valid(token):
		token = OAuthToken.new()
	else:
		token.changed.connect(update_expiration_check)


func _exit_tree() -> void:
	if is_instance_valid(token):
		token.changed.disconnect(update_expiration_check)


func _update_token(val: OAuthToken) -> void:
	if is_instance_valid(token) and is_inside_tree():
		token.changed.disconnect(update_expiration_check)
	token = val
	if is_instance_valid(token) and is_inside_tree():
		token.changed.connect(update_expiration_check)


func update_expiration_check() -> void:
	var current_time = Time.get_unix_time_from_system()
	var expiration = token.get_expiration()
	if expiration == 0: 
		_expiration_check_timer.stop()
		return
	_expiration_check_timer.start(expiration - current_time - SECONDS_TO_CHECK_EARLIER)
	logDebug("Refresh token (%s) in %s seconds" % [token._identifier, roundf(_expiration_check_timer.wait_time)])


## Checks if tokens expires and starts refreshing it. (called often hold footprintt small)
func _check_token_refresh() -> void:
	if _requesting_token: return

	if token_needs_refresh():
		logInfo("Token (%s) needs refresh" % token._identifier)
		refresh_tokens()


## Requests the tokens
func request_token(grant_type: String, auth_code: String = ""):
	if _requesting_token: return
	_requesting_token = true
	logInfo("Request token (for %s) via '%s'" % [token._identifier, grant_type])
	var request_params = [
		"grant_type=%s" % grant_type,
		"client_id=%s" % oauth_setting.client_id,
		"client_secret=%s" % oauth_setting.get_client_secret()
	]

	if auth_code != "":
		request_params.append("code=%s" % auth_code)
	if grant_type == "authorization_code":
		request_params.append("&redirect_uri=%s" % oauth_setting.redirect_url)

	var request_body = "&".join(request_params)
	var request = _http_client.request(oauth_setting.token_url, HTTPClient.METHOD_POST, HEADERS, request_body)
	await _handle_token_request(request)
	_requesting_token = false


func request_device_token(device_code_repsonse: OAuthDeviceCodeResponse, scopes: String, grant_type: String = "urn:ietf:params:oauth:grant-type:device_code") -> void:
	if _requesting_token: return
	_requesting_token = true
	logInfo("request token (for %s) via urn:ietf:params:oauth:grant-type:device_code" % token._identifier)
	var parameters = [
		"client_id=%s" % oauth_setting.client_id,
		"grant_type=%s" % grant_type,
		"device_code=%s" % device_code_repsonse.device_code,
		"scopes=%s" % scopes
	]

	var request_body = "&".join(parameters)

	# Time when the code is expired and we don't poll anymore
	var expire_data = Time.get_unix_time_from_system() + device_code_repsonse.expires_in

	while expire_data > Time.get_unix_time_from_system():
		var request = _http_client.request(oauth_setting.token_url, HTTPClient.METHOD_POST, HEADERS, request_body)
		var response = await _http_client.wait_for_request(request)
		var response_string: String = response.response_data.get_string_from_utf8()
		var response_data = JSON.parse_string(response_string)
		if response.response_code == 200:
			_update_tokens_from_response(response_data)
			_requesting_token = false
			return
		elif response.response_code == 400 && response_string.contains("authorization_pending"):
			# Awaits for this amount of time until retry
			await get_tree().create_timer(device_code_repsonse.interval, true, false, true).timeout
		elif response.response_code == 400:
			unauthenticated.emit()
			_requesting_token = false
			return

	# Handle Timeout
	unauthenticated.emit()
	_requesting_token = false


## Uses the refresh token if possible to refresh all tokens
func refresh_tokens() -> void:
	if not oauth_setting.is_valid(): 
		logDebug("Try to refresh token (%s) but oauth settings are invalid. Can't refresh token." % token._identifier)
		return

	if _requesting_token: return
	_requesting_token = true
	logInfo("use refresh (%s) token" % token._identifier)
	if token.has_refresh_token():
		var request_body = "client_id=%s&client_secret=%s&refresh_token=%s&grant_type=refresh_token" % [oauth_setting.client_id, oauth_setting.get_client_secret(), token.get_refresh_token()]
		var request = _http_client.request(oauth_setting.token_url, HTTPClient.METHOD_POST, HEADERS, request_body)
		if await _handle_token_request(request):
			logInfo("token (%s) got refreshed" % token._identifier)
		else:
			unauthenticated.emit()
	else:
		unauthenticated.emit()
	_requesting_token = false


## Gets information from the response and update values returns true when success otherwise false
func _handle_token_request(request: OAuthHTTPClient.RequestData) -> bool:
	var response = await _http_client.wait_for_request(request)
	var response_string = response.response_data.get_string_from_utf8()
	var result = JSON.parse_string(response_string)
	if response.response_code == 200:
		_update_tokens_from_response(result)
		return true
	else:
		# Reset expiration cause token wasn't refreshed correctly.
		token.invalidate()
	logError("token (for %s) could not be fetched ResponseCode %s / Body %s" % [token._identifier, response.response_code, response_string])
	return false


func _update_tokens_from_response(result: Dictionary):
	var scopes: Array[String] = []
	for scope in result.get("scope", []): scopes.append(scope)

	update_tokens(result["access_token"], \
		result.get("refresh_token", ""), \
		result.get("expires_in", -1), \
		scopes)


## Updates the token. Result is the response data of an token request.
func update_tokens(access_token: String, refresh_token: String = "", expires_in: int = -1, scopes: Array[String] = []):
	token.update_values(access_token, refresh_token, expires_in, scopes)
	token_resolved.emit(token)
	logInfo("token (%s) resolved" % token._identifier)


func get_token_expiration() -> String:
	return Time.get_datetime_string_from_unix_time(token._expire_date)


## Checks if the token are valud
func is_token_valid() -> bool:
	return token.is_token_valid()


## Checks if the token is expired and can be refreshed
func token_needs_refresh() -> bool:
	return !token.is_token_valid() && token.has_refresh_token()


func get_access_token() -> String: return await token.get_access_token()


func has_refresh_token() -> bool: return token.has_refresh_token()


func get_scopes() -> PackedStringArray: return token.get_scopes()


# === LOGGER ===
static var logger: Dictionary = {}


static func set_logger(error: Callable, info: Callable, debug: Callable) -> void:
	logger.debug = debug
	logger.info = info
	logger.error = error


static func logDebug(text: String) -> void:
	if logger.has("debug"): logger.debug.call(text)


static func logInfo(text: String) -> void:
	if logger.has("info"): logger.info.call(text)


static func logError(text: String) -> void:
	if logger.has("error"): logger.error.call(text)
