# MIT License
#
# Copyright (c) 2020-2020 Macaroni Studios AB
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

class_name _GotmImpl
#warnings-disable


# Utility sorter for 'sort_custom'.
class LobbySorter:
	var fetch
	var g

	func sort(lhs, rhs) -> bool:
		var a
		var b
		if fetch.sort_property.empty():
			a = lhs._impl.created
			b = rhs._impl.created
		else:
			a = lhs._impl.props[fetch.sort_property]
			b = rhs._impl.props[fetch.sort_property]
		
		if fetch.sort_ascending:
			return _GotmImplUtility.is_less(a, b)
		else:
			return _GotmImplUtility.is_greater(a, b)


# Generate 20 characters long random string.
static func _generate_id() -> String:
	var g = _get_gotm()
	var id: String = ""
	for i in range(20):
		id += g._impl.chars[g._impl.rng.randi() % g._impl.chars.length()]
	return id


# Retrieve scene statically via Engine singleton.
static func _get_tree() -> SceneTree:
	return Engine.get_main_loop() as SceneTree


# Get autoloaded Gotm instance from scene's root.
static func _get_gotm() -> Node:
	return _get_tree().root.get_node("Gotm")


# Simplify string somewhat. We want exact matches, but with some reasonable fuzziness.
static func _init_search_string_encoders() -> Array:
	var encoders: Array = [
		["[àáâãäå]", "a"],
		["[èéêë]", "e"],
		["[ìíîï]", "i"],
		["[òóôõöő]", "o"],
		["[ùúûüű]", "u"],
		["[ýŷÿ]", "y"],
		["ñ", "n"],
		["[çc]", "k"],
		["ß", "s"],
		["[-/]", " "],
		["[^a-z0-9 ]", ""],
		["\\s+", " "],
		["^\\s+", ""],
		["\\s+$", ""]
	]
	for encoder in encoders:
		var regex: RegEx = RegEx.new()
		regex.compile(encoder[0])
		encoder[0] = regex
		
	return encoders


# Initialize socket for fetching lobbies on local network.
static func _init_socket() -> void:
	var g = _get_gotm()
	if not g._impl.sockets:
		g._impl.sockets = []
		var is_listening = false
		for i in range(5):
			var socket = PacketPeerUDP.new()
			if socket.has_method("set_broadcast_enabled"):
				socket.set_broadcast_enabled(true)
			if not is_listening:
				is_listening = socket.listen(8075 + i) == OK
			socket.set_dest_address("255.255.255.255", 8075 + i)
			g._impl.sockets.push_back(socket)
		
		if not is_listening:
			push_error("Failed to listen for lobbies. All ports 8075-8079 are busy.")


# Attach some global state to autoloaded Gotm instance.
static func _initialize(GotmLobbyT, GotmUserT) -> void:
	var g = _get_gotm()
		
	g._impl = {
		"lobbies": [],
		"chars": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-",
		"rng": RandomNumberGenerator.new(),
		"search_string_encoders": _init_search_string_encoders(),
		"sockets": null,
		"lobby_requests": {},
		"GotmLobbyT": GotmLobbyT,
		"GotmUserT": GotmUserT,
		"is_listening": false
	}
	g._impl.rng.randomize()
	g.user._impl.id = _generate_id()
	g.user.address = "localhost"


static func _process() -> void:
	var g = _get_gotm()
	
	if g.lobby:
		if OS.get_system_time_msecs() - g.lobby._impl.last_heartbeat > 2500:
			_init_socket()
			_put_sockets({
				"op": "peer_heartbeat", 
				"data": {
					"lobby_id": g.lobby.id,
					"id": g.user._impl.id
				}
			})
			g.lobby._impl.last_heartbeat = OS.get_system_time_msecs()
	
	if g._impl.sockets:
		for socket in g._impl.sockets:
			while socket.get_available_packet_count() > 0:
				var v = socket.get_var()
				if v.op == "get_lobbies":
					var data = null
					if g.lobby and g.lobby.is_host():
						data = {
							"id": g.lobby.id,
							"name": g.lobby.name,
							"peers": [],
							"invite_link": g.lobby.invite_link,
							"_impl": g.lobby._impl
						}
					
					_put_sockets({"op": "lobby", "data": data, "id": v.id})
				elif v.op == "leave_lobby":
					if g.lobby and v.data.lobby_id == g.lobby.id:
						if g.lobby.is_host():
							_put_sockets({
								"op": "peer_left", 
								"data": {
									"lobby_id": g.lobby.id, 
									"id": v.data.id
								}
							})
						elif v.data.id == g.lobby.host._impl.id:
							_leave_lobby(g.lobby)
				elif v.op == "join_lobby":
					var data = null
					if g.lobby and g.lobby.is_host() and v.data.lobby_id == g.lobby.id:
						_put_sockets({
							"op": "peer_joined", 
							"data": {
								"lobby_id": g.lobby.id, 
								"address": socket.get_packet_ip(),
								"id": v.data.id
							},
							"id": v.id
						})
						
						var peers = []
						for peer in g.lobby.peers:
							peers.push_back({"address": peer.address, "_impl": peer._impl})
						data = {
							"id": g.lobby.id,
							"name": g.lobby.name,
							"peers": peers,
							"invite_link": g.lobby.invite_link,
							"_impl": g.lobby._impl
						}
					_put_sockets({"op": "lobby", "data": data, "id": v.id})
				elif v.op == "peer_left":
					if g.lobby and v.data.lobby_id == g.lobby.id:
						for peer in g.lobby.peers.duplicate():
							if peer._impl.id == v.data.id:
								g.lobby.peers.erase(peer)
								g.lobby.emit_signal("peer_left", peer)
				elif v.op == "peer_joined":
					if g.lobby and v.data.lobby_id == g.lobby.id and not g._impl.lobby_requests.has(v.id):
						var peer = g._impl.GotmUserT.new()
						peer.address = v.data.address
						peer._impl.id = v.data.id
						g.lobby.peers.push_back(peer)
						g.lobby.emit_signal("peer_joined", peer)
						if g.lobby.is_host():
							g.lobby._impl.heartbeats[v.data.id] = OS.get_system_time_msecs()
				elif v.op == "peer_heartbeat":
					if g.lobby and v.data.lobby_id == g.lobby.id:
						g.lobby._impl.heartbeats[v.data.id] = OS.get_system_time_msecs()
				elif v.op == "lobby":
					if v.data and g._impl.lobby_requests.has(v.id):
						var lobby = g._impl.GotmLobbyT.new()
						var peers = []
						if not v.data._impl.host_id.empty():
							v.data.peers.push_back({
								"address": socket.get_packet_ip(), 
								"_impl": {
									"id": v.data._impl.host_id
								}
							})
						for peer in v.data.peers:
							var p = g._impl.GotmUserT.new()
							p.address = peer.address
							p._impl = peer._impl
							peers.push_back(p)
							
						lobby.hidden = false
						lobby.locked = false
						lobby.id = v.data.id
						lobby.name = v.data.name
						lobby.peers = peers
						lobby.invite_link = v.data.invite_link
						lobby._impl = v.data._impl
						lobby._impl.address = socket.get_packet_ip()
						lobby.me.address = "127.0.0.1"
						lobby.host.address = socket.get_packet_ip()
						lobby.host._impl.id = v.data._impl.host_id
						g._impl.lobby_requests[v.id].push_back(lobby)
						
			if g.lobby:
				for peer_id in g.lobby._impl.heartbeats.duplicate():
					if OS.get_system_time_msecs() - g.lobby._impl.heartbeats[peer_id] > 10000:
						if g.lobby.is_host():
							_put_sockets({
								"op": "peer_left", 
								"data": {
									"lobby_id": g.lobby.id, 
									"id": peer_id
								}
							})
							g.lobby._impl.heartbeats.erase(peer_id)
						elif peer_id == g.lobby.host._impl.id:
							_leave_lobby(g.lobby)
							break
	



# Improve search experience a little by adding fuzziness.
static func _encode_search_string(s: String) -> String:
	s = s.to_lower()
	var encoders: Array = _get_gotm()._impl.search_string_encoders
	for encoder in encoders:
		s = encoder[0].sub(s, encoder[1], true)
	return s


# Return true if 'lobby' matches filter options in 'fetch'.
static func _match_lobby(lobby, fetch) -> bool:
	if lobby.locked or lobby.hidden:
		return false
	
	if not fetch.filter_name.empty():
		var name: String = _encode_search_string(lobby.name)
		var query: String = _encode_search_string(fetch.filter_name)
		if not query.empty() and name.find(query) < 0:
			return false
	
	var lobby_props: Dictionary = {}
	for key in lobby._impl.filterable_props:
		if not lobby._impl.props.has(key):
			return false
		if not fetch.filter_properties.has(key):
			return false
		
		var lhs = fetch.filter_properties[key]
		var rhs = lobby._impl.props[key]
		if lhs != null and lhs != rhs:
			return false
		
	return true


# Used to detect changes.
static func _stringify_fetch_state(fetch) -> String:
	var d: Array = [
		fetch.filter_name,
		fetch.filter_properties,
		fetch.sort_property,
		fetch.sort_property,
	fetch.sort_ascending,
	fetch.sort_min,
	fetch.sort_max,
	fetch.sort_min_exclusive,
	fetch.sort_max_exclusive
	]
	return JSON.print(d)
	


# Return sorted copy of 'lobbies' using sort options in 'fetch'.
static func _sort_lobbies(lobbies: Array, fetch) -> Array:
	var sorted: Array = []
	var g = _get_gotm()
	for lobby in lobbies:
		if fetch.sort_property.empty():
			sorted.push_back(lobby)
			
		var v = lobby._impl.props.get(fetch.sort_property)
		if v == null:
			continue
		if fetch.sort_min != null:
			if _GotmImplUtility.is_less(v, fetch.sort_min):
				continue
			if fetch.sort_min_exclusive and not _GotmImplUtility.is_greater(v, fetch.sort_min):
				continue
		if fetch.sort_max != null:
			if _GotmImplUtility.is_greater(v, fetch.sort_max):
				continue
			if fetch.sort_max_exclusive and not _GotmImplUtility.is_less(v, fetch.sort_max):
				continue
		
		sorted.push_back(lobby)
		
	
	var sorter: LobbySorter = LobbySorter.new()
	sorter.fetch = fetch
	sorter.g = g
	sorted.sort_custom(sorter, "sort")
	
	return sorted	


static func _put_sockets(v: Dictionary):
	_init_socket()
	for socket in _get_gotm()._impl.sockets:
		socket.put_var(v)


static func _request_lobbies() -> Array:
	var g = _get_gotm()
	var request_id: String = _generate_id()
	g._impl.lobby_requests[request_id] = []
	_put_sockets({"op": "get_lobbies", "id": request_id})
	yield(g.get_tree().create_timer(0.5), "timeout")
	
	var lobbies = g._impl.lobby_requests[request_id]
	g._impl.lobby_requests.erase(request_id)
	return lobbies


static func _request_join(lobby_id: String):
	var g = _get_gotm()
	var request_id: String = _generate_id()
	g._impl.lobby_requests[request_id] = []
	_put_sockets({
		"op": "join_lobby", 
		"id": request_id, 
		"data": {
			"lobby_id": lobby_id,
			"id": g.user._impl.id
		}
	})
	yield(g.get_tree().create_timer(0.5), "timeout")
	
	var lobbies = g._impl.lobby_requests[request_id]
	g._impl.lobby_requests.erase(request_id)
	for lobby in lobbies:
		if lobby:
			return lobby
	return null


static func _fetch_lobbies(fetch, count: int, type: String) -> Array:
	var g = _get_gotm()
	
	# Reset fetch state if user has modified any options.
	var stringified_state: String = _stringify_fetch_state(fetch)
	if not fetch._impl.has("last_state") or stringified_state != fetch._impl.last_state:
		fetch._impl.last_state = stringified_state
		fetch._impl.last_lobby = -1
		fetch._impl.start_lobby = -1
	
	
	# Apply filter options
	var lobbies: Array = []
	for lobby in yield(_request_lobbies(), "completed") + g._impl.lobbies:
		if _match_lobby(lobby, fetch):
			lobbies.push_back(lobby)
	
	# Apply sort options
	lobbies = _sort_lobbies(lobbies, fetch)
	count = min(8, count)
	var index: int = 0
	if type == "first":
		index = 0
	elif type == "next":
		index = fetch._impl.last_lobby + 1
	elif type == "current":
		index = fetch._impl.start_lobby + 1
	elif type == "previous":
		index = max(fetch._impl.start_lobby - count, 0)
	
	# Get 'count' lobbies.
	var result: Array = []
	for i in range(index, min(index + count, lobbies.size())):
		result.push_back(lobbies[i])
		
	# Write down last lobby for subsequent 'next' calls.
	if not result.empty():
		var start: int = lobbies.find(result.front()) - 1
		fetch._impl.start_lobby = max(start, -1)
		fetch._impl.last_lobby = lobbies.find(result.back())
	elif index > 0:
		fetch._impl.start_lobby = fetch._impl.last_lobby
	
	yield(_get_tree().create_timer(0.25), "timeout") # fake delay
	return result


# Common initialization.
static func _add_lobby(lobby):
	var g = _get_gotm()
	
	lobby.id = _generate_id()
	lobby.invite_link = "https://gotm.io/my-studio/my-game/"
	lobby.invite_link += "?connectToken=" + _generate_id()
	lobby._impl = {
		# Not exposed to user, so doesn't have to be a real timestamp.
		"created": OS.get_system_time_msecs(),
		"props": {},
		"sortable_props": [],
		"filterable_props": [],
		"heartbeats": {},
		"last_heartbeat": 0,
		"host_id": "",
		"address": ""
	}
	lobby.me._impl.id = g.user._impl.id
	
	g._impl.lobbies.push_back(lobby)
	return lobby


static func _host_lobby(lobby):
	var g = _get_gotm()
	_leave_lobby(g.lobby)
	
	lobby = _add_lobby(lobby)
	lobby._impl.address = "127.0.0.1"
	lobby.host.address = "127.0.0.1"
	lobby._impl.host_id = g.user._impl.id
	lobby.host._impl.id = g.user._impl.id
	lobby.me.address = "127.0.0.1"
	g.lobby = lobby
	g.emit_signal("lobby_changed")
	
	_init_socket()
		
	return lobby


static func _join_lobby(lobby) -> bool:
	var g = _get_gotm()
	_leave_lobby(g.lobby)
	
	if not g._impl.lobbies.has(lobby):
		lobby = yield(_request_join(lobby.id), "completed")
	else:
		yield(g.get_tree().create_timer(0.25), "timeout")
	
	if not lobby or lobby.locked:
		return false	
	
	lobby.host.address = lobby._impl.address
	lobby.host._impl.id = lobby._impl.host_id
	lobby.me.address = "127.0.0.1"
	g.lobby = lobby
	g.emit_signal("lobby_changed")
	return true


static func _is_lobby_host(lobby) -> bool:
	var g = _get_gotm()
	return lobby.host._impl.id == g.user._impl.id


static func _kick_lobby_peer(lobby, peer) -> bool:
	var g = _get_gotm()
	
	if not lobby.is_host():
		return false
	
	if g.user._impl.id == peer._impl.id:
		_leave_lobby(lobby)
	else:
		for p in lobby.peers.duplicate():
			if p._impl.id != peer._impl.id:
				continue
			lobby.peers.erase(p)
			if lobby == g.lobby:
				lobby.emit_signal("peer_left", p)
			break
	
	return true


static func _leave_lobby(lobby) -> void:
	if not lobby:
		return
	
	var g = _get_gotm()
	if g.lobby == lobby:
		if lobby.host.address == lobby.me.address:
			g._impl.lobbies.erase(lobby)
		lobby.me.address = ""
		lobby.host.address = ""
		_put_sockets({
			"op": "leave_lobby", 
			"data": {
				"lobby_id": lobby.id,
				"id": g.user._impl.id
			}
		})
		g.lobby = null
		g.emit_signal("lobby_changed")


static func _truncate_string(s: String) -> String:
	return s.substr(0, 64)

static func _set_lobby_property(lobby, name: String, value) -> void:
	name = _truncate_string(name)
	if value == null:
		lobby._impl.props.erase(name)
		
	match typeof(value):
		TYPE_BOOL:
			pass
		TYPE_INT:
			pass
		TYPE_REAL:
			pass
		TYPE_STRING:
			value = _truncate_string(value)
		_:
			push_error("Invalid lobby property type.")
			return
			
	lobby._impl.props[name] = value


static func _get_lobby_property(lobby, name: String):
	return lobby._impl.props[_truncate_string(name)]

static func _set_lobby_filterable(lobby, property_name: String, filterable: bool) -> void:
	property_name = _truncate_string(property_name)
	if not filterable:
		lobby._impl.filterable_props.erase(property_name)
	elif not lobby._impl.filterable_props.has(property_name):
		lobby._impl.filterable_props.push_back(property_name)


static func _set_lobby_sortable(lobby, property_name: String, sortable: bool) -> void:
	property_name = _truncate_string(property_name)
	if not sortable:
		lobby._impl.sortable_props.erase(property_name)
	elif not lobby._impl.sortable_props.has(property_name):
		lobby._impl.sortable_props.push_back(property_name)
