extends Node
class_name Gift

# The underlying websocket sucessfully connected to Twitch IRC.
signal twitch_connected
# The connection has been closed. Not emitted if Twitch IRC announced a reconnect.
signal twitch_disconnected
# The connection to Twitch IRC failed.
signal twitch_unavailable
# Twitch IRC requested the client to reconnect. (Will be unavailable until next connect)
signal twitch_reconnect
# User token from Twitch has been fetched.
signal user_token_received(token_data)
# User token is valid.
signal user_token_valid
# User token is no longer valid.
signal user_token_invalid
# The client tried to login to Twitch IRC. Returns true if successful, else false.
signal login_attempt(success)
# User sent a message in chat.
signal chat_message(sender_data, message)
# User sent a whisper message.
signal whisper_message(sender_data, message)
# Initial channel data received
signal channel_data_received(channel_name)
# Unhandled data passed through
signal unhandled_message(message, tags)
# A command has been called with invalid arg count
signal cmd_invalid_argcount(cmd_name, sender_data, cmd_data, arg_ary)
# A command has been called with insufficient permissions
signal cmd_no_permission(cmd_name, sender_data, cmd_data, arg_ary)
# Twitch IRC ping is about to be answered with a pong.
signal pong


# The underlying websocket sucessfully connected to Twitch EventSub.
signal events_connected
# The connection to Twitch EventSub failed.
signal events_unavailable
# The underlying websocket disconnected from Twitch EventSub.
signal events_disconnected
# The id has been received from the welcome message.
signal events_id(id)
# Twitch directed the bot to reconnect to a different URL
signal events_reconnect
# Twitch revoked a event subscription
signal events_revoked(event, reason)

# Refer to https://dev.twitch.tv/docs/eventsub/eventsub-reference/ data contained in the data dictionary.
signal event(type, data)

@export_category("IRC")

## Messages starting with one of these symbols are handled as commands. '/' will be ignored, reserved by Twitch.
@export var command_prefixes : Array[String] = ["!"]

## Time to wait in msec after each sent chat message. Values below ~310 might lead to a disconnect after 100 messages.
@export var chat_timeout_ms : int = 320

## Scopes to request for the token. Look at https://dev.twitch.tv/docs/authentication/scopes/ for a list of all available scopes.
@export var scopes : Array[String] = ["chat:edit", "chat:read"]

@export_category("Emotes/Badges")

## If true, caches emotes/badges to disk, so that they don't have to be redownloaded on every restart.
## This however means that they might not be updated if they change until you clear the cache.
@export var disk_cache : bool = false

## Disk Cache has to be enbaled for this to work
@export_file var disk_cache_path : String = "user://gift/cache"

var client_id : String = ""
var client_secret : String = ""
var username : String = ""
var user_id : String = ""
var token : Dictionary = {}

# Twitch disconnects connected clients if too many chat messages are being sent. (At about 100 messages/30s).
# This queue makes sure messages aren't sent too quickly.
var chat_queue : Array[String] = []
var last_msg : int = Time.get_ticks_msec()
# Mapping of channels to their channel info, like available badges.
var channels : Dictionary = {}
# Last Userstate of the bot for channels. Contains <channel_name> -> <userstate_dictionary> entries.
var last_state : Dictionary = {}
# Dictionary of commands, contains <command key> -> <Callable> entries.
var commands : Dictionary = {}

var eventsub : WebSocketPeer
var eventsub_messages : Dictionary = {}
var eventsub_connected : bool = false
var eventsub_restarting : bool = false
var eventsub_reconnect_url : String = ""
var session_id : String = ""
var keepalive_timeout : int = 0
var last_keepalive : int = 0

var websocket : WebSocketPeer
var server : TCPServer = TCPServer.new()
var peer : StreamPeerTCP
var connected : bool = false
var user_regex : RegEx = RegEx.new()
var twitch_restarting : bool = false

const USER_AGENT : String = "User-Agent: GIFT/4.1.5 (Godot Engine)"

enum RequestType {
	EMOTE,
	BADGE,
	BADGE_MAPPING
}

var caches := {
	RequestType.EMOTE: {},
	RequestType.BADGE: {},
	RequestType.BADGE_MAPPING: {}
}

# Required permission to execute the command
enum PermissionFlag {
	EVERYONE = 0,
	VIP = 1,
	SUB = 2,
	MOD = 4,
	STREAMER = 8,
	# Mods and the streamer
	MOD_STREAMER = 12,
	# Everyone but regular viewers
	NON_REGULAR = 15
}

# Where the command should be accepted
enum WhereFlag {
	CHAT = 1,
	WHISPER = 2
}

func _init():
	user_regex.compile("(?<=!)[\\w]*(?=@)")
	if (disk_cache):
		for key in RequestType.keys():
			if (!DirAccess.dir_exists_absolute(disk_cache_path + "/" + key)):
				DirAccess.make_dir_recursive_absolute(disk_cache_path + "/" + key)

# Authenticate to authorize GIFT to use your account to process events and messages.
func authenticate(client_id, client_secret) -> void:
	self.client_id = client_id
	self.client_secret = client_secret
	print("Checking token...")
	if (FileAccess.file_exists("user://gift/auth/user_token")):
		var file : FileAccess = FileAccess.open_encrypted_with_pass("user://gift/auth/user_token", FileAccess.READ, client_secret)
		token = JSON.parse_string(file.get_as_text())
		if (token.has("scope") && scopes.size() != 0):
			if (scopes.size() != token["scope"].size()):
				get_token()
				token = await(user_token_received)
			else:
				for scope in scopes:
					if (!token["scope"].has(scope)):
						get_token()
						token = await(user_token_received)
	else:
		get_token()
		token = await(user_token_received)
	username = await(is_token_valid(token["access_token"]))
	while (username == ""):
		print("Token invalid.")
		var refresh : String = token.get("refresh_token", "")
		if (refresh != ""):
			refresh_access_token(refresh)
		else:
			get_token()
		token = await(user_token_received)
		username = await(is_token_valid(token["access_token"]))
	print("Token verified.")
	user_token_valid.emit()
	refresh_token()

func refresh_access_token(refresh : String) -> void:
	print("Refreshing access token.")
	var request : HTTPRequest = HTTPRequest.new()
	add_child(request)
	request.request("https://id.twitch.tv/oauth2/token", [USER_AGENT, "Content-Type: application/x-www-form-urlencoded"], HTTPClient.METHOD_POST, "grant_type=refresh_token&refresh_token=%s&client_id=%s&client_secret=%s" % [refresh.uri_encode(), client_id, client_secret])
	var reply : Array = await(request.request_completed)
	request.queue_free()
	var response : Dictionary = JSON.parse_string(reply[3].get_string_from_utf8())
	if (response.has("error")):
		print("Refresh failed, requesting new token.")
		get_token()
	else:
		token = response
		var file : FileAccess = FileAccess.open_encrypted_with_pass("user://gift/auth/user_token", FileAccess.WRITE, client_secret)
		file.store_string(reply[3].get_string_from_utf8())
		user_token_received.emit(response)

# Gets a new auth token from Twitch.
func get_token() -> void:
	print("Fetching new token.")
	var scope = ""
	for i in scopes.size() - 1:
		scope += scopes[i]
		scope += " "
	if (scopes.size() > 0):
		scope += scopes[scopes.size() - 1]
	scope = scope.uri_encode()
	OS.shell_open("https://id.twitch.tv/oauth2/authorize?response_type=code&client_id=" + client_id +"&redirect_uri=http://localhost:18297&scope=" + scope)
	server.listen(18297)
	print("Waiting for user to login.")
	while(!peer):
		peer = server.take_connection()
		OS.delay_msec(100)
	while(peer.get_status() == peer.STATUS_CONNECTED):
		peer.poll()
		if (peer.get_available_bytes() > 0):
			var response = peer.get_utf8_string(peer.get_available_bytes())
			if (response == ""):
				print("Empty response. Check if your redirect URL is set to http://localhost:18297.")
				return
			var start : int = response.find("?")
			response = response.substr(start + 1, response.find(" ", start) - start)
			var data : Dictionary = {}
			for entry in response.split("&"):
				var pair = entry.split("=")
				data[pair[0]] = pair[1] if pair.size() > 0 else ""
			if (data.has("error")):
				var msg = "Error %s: %s" % [data["error"], data["error_description"]]
				print(msg)
				send_response(peer, "400 BAD REQUEST",  msg.to_utf8_buffer())
				peer.disconnect_from_host()
				break
			else:
				print("Success.")
				send_response(peer, "200 OK", "Success!".to_utf8_buffer())
				peer.disconnect_from_host()
				var request : HTTPRequest = HTTPRequest.new()
				add_child(request)
				request.request("https://id.twitch.tv/oauth2/token", [USER_AGENT, "Content-Type: application/x-www-form-urlencoded"], HTTPClient.METHOD_POST, "client_id=" + client_id + "&client_secret=" + client_secret + "&code=" + data["code"] + "&grant_type=authorization_code&redirect_uri=http://localhost:18297")
				var answer = await(request.request_completed)
				if (!DirAccess.dir_exists_absolute("user://gift/auth")):
					DirAccess.make_dir_recursive_absolute("user://gift/auth")
				var file : FileAccess = FileAccess.open_encrypted_with_pass("user://gift/auth/user_token", FileAccess.WRITE, client_secret)
				var token_data = answer[3].get_string_from_utf8()
				file.store_string(token_data)
				request.queue_free()
				user_token_received.emit(JSON.parse_string(token_data))
				break
		OS.delay_msec(100)

func send_response(peer : StreamPeer, response : String, body : PackedByteArray) -> void:
	peer.put_data(("HTTP/1.1 %s\r\n" % response).to_utf8_buffer())
	peer.put_data("Server: GIFT (Godot Engine)\r\n".to_utf8_buffer())
	peer.put_data(("Content-Length: %d\r\n"% body.size()).to_utf8_buffer())
	peer.put_data("Connection: close\r\n".to_utf8_buffer())
	peer.put_data("Content-Type: text/plain; charset=UTF-8\r\n".to_utf8_buffer())
	peer.put_data("\r\n".to_utf8_buffer())
	peer.put_data(body)

# If the token is valid, returns the username of the token bearer. Returns an empty String if the token was invalid.
func is_token_valid(token : String) -> String:
	var request : HTTPRequest = HTTPRequest.new()
	add_child(request)
	request.request("https://id.twitch.tv/oauth2/validate", [USER_AGENT, "Authorization: OAuth " + token])
	var data = await(request.request_completed)
	request.queue_free()
	if (data[1] == 200):
		var payload : Dictionary = JSON.parse_string(data[3].get_string_from_utf8())
		user_id = payload["user_id"]
		return payload["login"]
	return ""

func refresh_token() -> void:
	await(get_tree().create_timer(3600).timeout)
	if (await(is_token_valid(token["access_token"])) == ""):
		user_token_invalid.emit()
		return
	else:
		refresh_token()
	var to_remove : Array[String] = []
	for entry in eventsub_messages.keys():
		if (Time.get_ticks_msec() - eventsub_messages[entry] > 600000):
			to_remove.append(entry)
	for n in to_remove:
		eventsub_messages.erase(n)

func _process(delta : float) -> void:
	if (websocket):
		websocket.poll()
		var state := websocket.get_ready_state()
		match state:
			WebSocketPeer.STATE_OPEN:
				if (!connected):
					twitch_connected.emit()
					connected = true
					print_debug("Connected to Twitch.")
				else:
					while (websocket.get_available_packet_count()):
						data_received(websocket.get_packet())
					if (!chat_queue.is_empty() && (last_msg + chat_timeout_ms) <= Time.get_ticks_msec()):
						send(chat_queue.pop_front())
						last_msg = Time.get_ticks_msec()
			WebSocketPeer.STATE_CLOSED:
				if (!connected):
					twitch_unavailable.emit()
					print_debug("Could not connect to Twitch.")
					websocket = null
				elif(twitch_restarting):
					print_debug("Reconnecting to Twitch...")
					twitch_reconnect.emit()
					connect_to_irc()
					await(twitch_connected)
					for channel in channels.keys():
						join_channel(channel)
					twitch_restarting = false
				else:
					print_debug("Disconnected from Twitch.")
					twitch_disconnected.emit()
					connected = false
					print_debug("Connection closed! [%s]: %s"%[websocket.get_close_code(), websocket.get_close_reason()])
	if (eventsub):
		eventsub.poll()
		var state := eventsub.get_ready_state()
		match state:
			WebSocketPeer.STATE_OPEN:
				if (!eventsub_connected):
					events_connected.emit()
					eventsub_connected = true
					print_debug("Connected to EventSub.")
				else:
					while (eventsub.get_available_packet_count()):
						process_event(eventsub.get_packet())
			WebSocketPeer.STATE_CLOSED:
				if(!eventsub_connected):
					print_debug("Could not connect to EventSub.")
					events_unavailable.emit()
					eventsub = null
				elif(eventsub_restarting):
					print_debug("Reconnecting to EventSub")
					eventsub.close()
					connect_to_eventsub(eventsub_reconnect_url)
					await(eventsub_connected)
					eventsub_restarting = false
				else:
					print_debug("Disconnected from EventSub.")
					events_disconnected.emit()
					eventsub_connected = false
					print_debug("Connection closed! [%s]: %s"%[websocket.get_close_code(), websocket.get_close_reason()])

func process_event(data : PackedByteArray) -> void:
	var msg : Dictionary = JSON.parse_string(data.get_string_from_utf8())
	if (eventsub_messages.has(msg["metadata"]["message_id"])):
		return
	eventsub_messages[msg["metadata"]["message_id"]] = Time.get_ticks_msec()
	var payload : Dictionary = msg["payload"]
	last_keepalive = Time.get_ticks_msec()
	match msg["metadata"]["message_type"]:
		"session_welcome":
			session_id = payload["session"]["id"]
			keepalive_timeout = payload["session"]["keepalive_timeout_seconds"]
			events_id.emit(session_id)
		"session_keepalive":
			if (payload.has("session")):
				keepalive_timeout = payload["session"]["keepalive_timeout_seconds"]
		"session_reconnect":
			eventsub_restarting = true
			eventsub_reconnect_url = payload["session"]["reconnect_url"]
			events_reconnect.emit()
		"revocation":
			events_revoked.emit(payload["subscription"]["type"], payload["subscription"]["status"])
		"notification":
			var event_data : Dictionary = payload["event"]
			event.emit(payload["subscription"]["type"], event_data)

# Connect to Twitch IRC. Make sure to authenticate first.
func connect_to_irc() -> bool:
	websocket = WebSocketPeer.new()
	websocket.connect_to_url("wss://irc-ws.chat.twitch.tv:443")
	print("Connecting to Twitch IRC.")
	await(twitch_connected)
	send("PASS oauth:%s" % [token["access_token"]], true)
	send("NICK " + username.to_lower())
	var success = await(login_attempt)
	if (success):
		connected = true
	return success

# Connect to Twitch EventSub. Make sure to authenticate first.
func connect_to_eventsub(url : String = "wss://eventsub.wss.twitch.tv/ws") -> void:
	eventsub = WebSocketPeer.new()
	eventsub.connect_to_url(url)
	print("Connecting to Twitch EventSub.")
	await(events_id)
	events_connected.emit()

# Refer to https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/ for details on
# which API versions are available and which conditions are required.
func subscribe_event(event_name : String, version : int, conditions : Dictionary) -> void:
	var data : Dictionary = {}
	data["type"] = event_name
	data["version"] = str(version)
	data["condition"] = conditions
	data["transport"] = {
		"method":"websocket",
		"session_id":session_id
	}
	var request : HTTPRequest = HTTPRequest.new()
	add_child(request)
	request.request("https://api.twitch.tv/helix/eventsub/subscriptions", [USER_AGENT, "Authorization: Bearer " + token["access_token"], "Client-Id:" + client_id, "Content-Type: application/json"], HTTPClient.METHOD_POST, JSON.stringify(data))
	var reply : Array = await(request.request_completed)
	request.queue_free()
	var response : Dictionary = JSON.parse_string(reply[3].get_string_from_utf8())
	if (response.has("error")):
		print("Subscription failed for event '%s'. Error %s (%s): %s" % [event_name, response["status"], response["error"], response["message"]])
		return
	print("Now listening to '%s' events." % event_name)

# Request capabilities from twitch.
func request_caps(caps : String = "twitch.tv/commands twitch.tv/tags twitch.tv/membership") -> void:
	send("CAP REQ :" + caps)

# Sends a String to Twitch.
func send(text : String, token : bool = false) -> void:
	websocket.send_text(text)
	if(OS.is_debug_build()):
		if(!token):
			print("< " + text.strip_edges(false))
		else:
			print("< PASS oauth:******************************")

# Sends a chat message to a channel. Defaults to the only connected channel.
func chat(message : String, channel : String = ""):
	var keys : Array = channels.keys()
	if(channel != ""):
		if (channel.begins_with("#")):
			channel = channel.right(-1)
		chat_queue.append("PRIVMSG #" + channel + " :" + message + "\r\n")
		chat_message.emit(SenderData.new(last_state[channels.keys()[0]]["display-name"], channel, last_state[channels.keys()[0]]), message)
	elif(keys.size() == 1):
		chat_queue.append("PRIVMSG #" + channels.keys()[0] + " :" + message + "\r\n")
		chat_message.emit(SenderData.new(last_state[channels.keys()[0]]["display-name"], channels.keys()[0], last_state[channels.keys()[0]]), message)
	else:
		print_debug("No channel specified.")

# Send a whisper message to a user by username. Returns a empty dictionary on success. If it failed, "status" will be present in the Dictionary.
func whisper(message : String, target : String) -> Dictionary:
	var user_data : Dictionary = await(user_data_by_name(target))
	if (user_data.has("status")):
		return user_data
	var response : int = await(whisper_by_uid(message, user_data["id"]))
	if (response != HTTPClient.RESPONSE_NO_CONTENT):
		return {"status": response}
	return {}

# Send a whisper message to a user by UID. Returns the response code.
func whisper_by_uid(message : String, target_id : String) -> int:
	var request : HTTPRequest = HTTPRequest.new()
	add_child(request)
	request.request("https://api.twitch.tv/helix/whispers", [USER_AGENT, "Authorization: Bearer " + token["access_token"], "Client-Id:" + client_id, "Content-Type: application/json"], HTTPClient.METHOD_POST, JSON.stringify({"from_user_id": user_id, "to_user_id": target_id, "message": message}))
	var reply : Array = await(request.request_completed)
	request.queue_free()
	if (reply[1] != HTTPClient.RESPONSE_NO_CONTENT):
		print("Error sending the whisper: " + reply[3].get_string_from_utf8())
	return reply[0]

# Returns the response as Dictionary. If it failed, "error" will be present in the Dictionary.
func user_data_by_name(username : String) -> Dictionary:
	var request : HTTPRequest = HTTPRequest.new()
	add_child(request)
	request.request("https://api.twitch.tv/helix/users?login=" + username, [USER_AGENT, "Authorization: Bearer " + token["access_token"], "Client-Id:" + client_id, "Content-Type: application/json"], HTTPClient.METHOD_GET)
	var reply : Array = await(request.request_completed)
	var response : Dictionary = JSON.parse_string(reply[3].get_string_from_utf8())
	request.queue_free()
	if (response.has("error")):
		print("Error fetching user data: " + reply[3].get_string_from_utf8())
		return response
	else:
		return response["data"][0]

func get_emote(emote_id : String, scale : String = "1.0") -> Texture2D:
	var texture : Texture2D
	var cachename : String = emote_id + "_" + scale
	var filename : String = disk_cache_path + "/" + RequestType.keys()[RequestType.EMOTE] + "/" + cachename + ".png"
	if !caches[RequestType.EMOTE].has(cachename):
		if (disk_cache && FileAccess.file_exists(filename)):
			texture = ImageTexture.new()
			var img : Image = Image.new()
			img.load_png_from_buffer(FileAccess.get_file_as_bytes(filename))
			texture.create_from_image(img)
		else:
			var request : HTTPRequest = HTTPRequest.new()
			add_child(request)
			request.request("https://static-cdn.jtvnw.net/emoticons/v1/" + emote_id + "/" + scale, [USER_AGENT,"Accept: */*"])
			var data = await(request.request_completed)
			request.queue_free()
			var img : Image = Image.new()
			img.load_png_from_buffer(data[3])
			texture = ImageTexture.create_from_image(img)
			texture.take_over_path(filename)
			if (disk_cache):
				DirAccess.make_dir_recursive_absolute(filename.get_base_dir())
				texture.get_image().save_png(filename)
		caches[RequestType.EMOTE][cachename] = texture
	return caches[RequestType.EMOTE][cachename]

func get_badge(badge_name : String, channel_id : String = "_global", scale : String = "1") -> Texture2D:
	var badge_data : PackedStringArray = badge_name.split("/", true, 1)
	var texture : Texture2D
	var cachename = badge_data[0] + "_" + badge_data[1] + "_" + scale
	var filename : String = disk_cache_path + "/" + RequestType.keys()[RequestType.BADGE] + "/" + channel_id + "/" + cachename + ".png"
	if (!caches[RequestType.BADGE].has(channel_id)):
		caches[RequestType.BADGE][channel_id] = {}
	if (!caches[RequestType.BADGE][channel_id].has(cachename)):
		if (disk_cache && FileAccess.file_exists(filename)):
			var img : Image = Image.new()
			img.load_png_from_buffer(FileAccess.get_file_as_bytes(filename))
			texture = ImageTexture.create_from_image(img)
			texture.take_over_path(filename)
		else:
			var map : Dictionary = caches[RequestType.BADGE_MAPPING].get(channel_id, await(get_badge_mapping(channel_id)))
			if (!map.is_empty()):
				if(map.has(badge_data[0])):
					var request : HTTPRequest = HTTPRequest.new()
					add_child(request)
					request.request(map[badge_data[0]]["versions"][badge_data[1]]["image_url_" + scale + "x"], [USER_AGENT,"Accept: */*"])
					var data = await(request.request_completed)
					var img : Image = Image.new()
					img.load_png_from_buffer(data[3])
					texture = ImageTexture.create_from_image(img)
					texture.take_over_path(filename)
					request.queue_free()
				elif channel_id != "_global":
					return await(get_badge(badge_name, "_global", scale))
			elif (channel_id != "_global"):
				return await(get_badge(badge_name, "_global", scale))
			if (disk_cache):
				DirAccess.make_dir_recursive_absolute(filename.get_base_dir())
				texture.get_image().save_png(filename)
		texture.take_over_path(filename)
		caches[RequestType.BADGE][channel_id][cachename] = texture
	return caches[RequestType.BADGE][channel_id][cachename]

func get_badge_mapping(channel_id : String = "_global") -> Dictionary:
	if caches[RequestType.BADGE_MAPPING].has(channel_id):
		return caches[RequestType.BADGE_MAPPING][channel_id]

	var filename : String = disk_cache_path + "/" + RequestType.keys()[RequestType.BADGE_MAPPING] + "/" + channel_id + ".json"
	if (disk_cache && FileAccess.file_exists(filename)):
		var cache = JSON.parse_string(FileAccess.get_file_as_string(filename))
		if "badge_sets" in cache:
			return cache["badge_sets"]

	var request : HTTPRequest = HTTPRequest.new()
	add_child(request)
	request.request("https://api.twitch.tv/helix/chat/badges" + ("/global" if channel_id == "_global" else "?broadcaster_id=" + channel_id), [USER_AGENT, "Authorization: Bearer " + token["access_token"], "Client-Id:" + client_id, "Content-Type: application/json"], HTTPClient.METHOD_GET)
	var reply : Array = await(request.request_completed)
	var response : Dictionary = JSON.parse_string(reply[3].get_string_from_utf8())
	var mappings : Dictionary = {}
	for entry in response["data"]:
		if (!mappings.has(entry["set_id"])):
			mappings[entry["set_id"]] = {"versions": {}}
		for version in entry["versions"]:
			mappings[entry["set_id"]]["versions"][version["id"]] = version
	request.queue_free()
	if (reply[1] == HTTPClient.RESPONSE_OK):
		caches[RequestType.BADGE_MAPPING][channel_id] = mappings
		if (disk_cache):
			DirAccess.make_dir_recursive_absolute(filename.get_base_dir())
			var file : FileAccess = FileAccess.open(filename, FileAccess.WRITE)
			file.store_string(JSON.stringify(mappings))
	else:
		print("Could not retrieve badge mapping for channel_id " + channel_id + ".")
		return {}
	return caches[RequestType.BADGE_MAPPING][channel_id]

func data_received(data : PackedByteArray) -> void:
	var messages : PackedStringArray = data.get_string_from_utf8().strip_edges(false).split("\r\n")
	var tags = {}
	for message in messages:
		if(message.begins_with("@")):
			var msg : PackedStringArray = message.split(" ", false, 1)
			message = msg[1]
			for tag in msg[0].split(";"):
				var pair = tag.split("=")
				tags[pair[0]] = pair[1]
		if (OS.is_debug_build()):
			print("> " + message)
		handle_message(message, tags)

# Registers a command on an object with a func to call, similar to connect(signal, instance, func).
func add_command(cmd_name : String, callable : Callable, max_args : int = 0, min_args : int = 0, permission_level : int = PermissionFlag.EVERYONE, where : int = WhereFlag.CHAT) -> void:
	commands[cmd_name] = CommandData.new(callable, permission_level, max_args, min_args, where)

# Removes a single command or alias.
func remove_command(cmd_name : String) -> void:
	commands.erase(cmd_name)

# Removes a command and all associated aliases.
func purge_command(cmd_name : String) -> void:
	var to_remove = commands.get(cmd_name)
	if(to_remove):
		var remove_queue = []
		for command in commands.keys():
			if(commands[command].func_ref == to_remove.func_ref):
				remove_queue.append(command)
		for queued in remove_queue:
			commands.erase(queued)

func add_alias(cmd_name : String, alias : String) -> void:
	if(commands.has(cmd_name)):
		commands[alias] = commands.get(cmd_name)

func add_aliases(cmd_name : String, aliases : PackedStringArray) -> void:
	for alias in aliases:
		add_alias(cmd_name, alias)

func handle_message(message : String, tags : Dictionary) -> void:
	if(message == "PING :tmi.twitch.tv"):
		send("PONG :tmi.twitch.tv")
		pong.emit()
		return
	var msg : PackedStringArray = message.split(" ", true, 3)
	match msg[1]:
		"NOTICE":
			var info : String = msg[3].right(-1)
			if (info == "Login authentication failed" || info == "Login unsuccessful"):
				print_debug("Authentication failed.")
				login_attempt.emit(false)
			elif (info == "You don't have permission to perform that action"):
				print_debug("No permission. Check if access token is still valid. Aborting.")
				user_token_invalid.emit()
				set_process(false)
			else:
				unhandled_message.emit(message, tags)
		"001":
			print_debug("Authentication successful.")
			login_attempt.emit(true)
		"PRIVMSG":
			var sender_data : SenderData = SenderData.new(user_regex.search(msg[0]).get_string(), msg[2], tags)
			handle_command(sender_data, msg[3].split(" ", true, 1))
			chat_message.emit(sender_data, msg[3].right(-1))
		"WHISPER":
			var sender_data : SenderData = SenderData.new(user_regex.search(msg[0]).get_string(), msg[2], tags)
			handle_command(sender_data, msg[3].split(" ", true, 1), true)
			whisper_message.emit(sender_data, msg[3].right(-1))
		"RECONNECT":
			twitch_restarting = true
		"USERSTATE", "ROOMSTATE":
			var room = msg[2].right(-1)
			if (!last_state.has(room)):
				last_state[room] = tags
				channel_data_received.emit(room)
			else:
				for key in tags:
					last_state[room][key] = tags[key]
		_:
			unhandled_message.emit(message, tags)

func handle_command(sender_data : SenderData, msg : PackedStringArray, whisper : bool = false) -> void:
	if(command_prefixes.has(msg[0].substr(1, 1))):
		var command : String  = msg[0].right(-2)
		var cmd_data : CommandData = commands.get(command)
		if(cmd_data):
			if(whisper == true && cmd_data.where & WhereFlag.WHISPER != WhereFlag.WHISPER):
				return
			elif(whisper == false && cmd_data.where & WhereFlag.CHAT != WhereFlag.CHAT):
				return
			var args = "" if msg.size() == 1 else msg[1]
			var arg_ary : PackedStringArray = PackedStringArray() if args == "" else args.split(" ")
			if(arg_ary.size() > cmd_data.max_args && cmd_data.max_args != -1 || arg_ary.size() < cmd_data.min_args):
				cmd_invalid_argcount.emit(command, sender_data, cmd_data, arg_ary)
				print_debug("Invalid argcount!")
				return
			if(cmd_data.permission_level != 0):
				var user_perm_flags = get_perm_flag_from_tags(sender_data.tags)
				if(user_perm_flags & cmd_data.permission_level == 0):
					cmd_no_permission.emit(command, sender_data, cmd_data, arg_ary)
					print_debug("No Permission for command!")
					return
			if(arg_ary.size() == 0):
				cmd_data.func_ref.call(CommandInfo.new(sender_data, command, whisper))
			else:
				cmd_data.func_ref.call(CommandInfo.new(sender_data, command, whisper), arg_ary)

func get_perm_flag_from_tags(tags : Dictionary) -> int:
	var flag = 0
	var entry = tags.get("badges")
	if(entry):
		for badge in entry.split(","):
			if(badge.begins_with("vip")):
				flag += PermissionFlag.VIP
			if(badge.begins_with("broadcaster")):
				flag += PermissionFlag.STREAMER
	entry = tags.get("mod")
	if(entry):
		if(entry == "1"):
			flag += PermissionFlag.MOD
	entry = tags.get("subscriber")
	if(entry):
		if(entry == "1"):
			flag += PermissionFlag.SUB
	return flag

func join_channel(channel : String) -> void:
	var lower_channel : String = channel.to_lower()
	channels[lower_channel] = {}
	send("JOIN #" + lower_channel)

func leave_channel(channel : String) -> void:
	var lower_channel : String = channel.to_lower()
	send("PART #" + lower_channel)
	channels.erase(lower_channel)
