"""
Main UnicodeEngine_RPG module.
"""
import time
import colorama; colorama.init()
from colorama import Fore, Back, Style
import sys
from typing import Callable, Union
from copy import deepcopy

from UnicodeEngine_RPG.chars import Char
from UnicodeEngine_RPG.getch import getch
from UnicodeEngine_RPG.player import Player
from UnicodeEngine_RPG.inventory import InventoryItem
from UnicodeEngine_RPG.utilities import display_text


class UnicodeEngine_RPG:
	def __init__(
			self,
			tilemap: list,
			player: Player,
			playable_area: tuple[int, int] | list[int, int] = (20, 10),
			controls: str = "wasdf",
			force_monochrome: bool = False,
			inventory: dict = None,
			noclip: bool = False
	):
		"""
		Initialization of a new engine instance.
		:param tilemap: A 2D list containing all the characters of the map,
			instances of the 'Char' class.
		:param player: A Player object.
		:param playable_area: A tuple of two integers representing the amount of characters
			displayed on each axis.
		:param controls: A string containing the characters to press for :
			- Forward ;
			- Left ;
			- Backward ;
			- Right ;
			- Action key
			('wasdf' by default)
		:param force_monochrome: Whether to disable color rendering. False by default.
		:param inventory: A dict of InventoryItem class instances and their keys.
		:param noclip: Whether to disable collisions altogether.
		"""
		self.tilemap = tilemap
		self.playable_area = playable_area
		self.player = player
		self.controls = controls
		self.force_monochrome = force_monochrome
		self.inventory = inventory if inventory is not None else {}
		self.quit = self._quit
		self.noclip = noclip

	def run(self, update: Callable = None):
		"""
		Launches the engine main loop.
		:param update: A function that will get called every frame, having as parameter the delta time.
		"""
		# Registers the update function
		self.update = update if update is not None else lambda dt: None

		# Asking the user to resize his console
		print("\n" * 20)
		for i in range(self.playable_area[0]):
			if i == self.playable_area[0] // 2:
				print("|   Please adjust your console size so that all these lines fit, and only these lines")
			else:
				print("|")
		getch()

		self._last_frame_executed = time.time()
		self.dt = 0
		self.keystroke = ""
		# Main loop
		while True:
			# Capping at 60 frames per second
			if time.time() - self._last_frame_executed < 1/15: continue
			self.dt = time.time() - self._last_frame_executed

			# Calling the update method
			self.update(self.dt)

			# Rendering the tilemap
			self.render_tiles(force_no_color=self.force_monochrome)

			# Getting the next keystroke
			self.keystroke = getch()

			if self.keystroke == "n":
				self.quit()

			# Displaying the controls
			elif self.keystroke == "c":
				self.display_controls()

			# If the action key is pressed
			elif self.keystroke == self.controls[4]:
				# If the player is doing an action, we check the tile in front of it for the action.
				# Beforehand, we create a position list for the direction, so we know later which tile the player is
				# interacting with.
				relative_tile_position = [0, 0]
				if self.player.current_direction == 1: relative_tile_position[0] = -1
				elif self.player.current_direction == 2: relative_tile_position[0] = 1
				elif self.player.current_direction == 3: relative_tile_position[1] = 1
				elif self.player.current_direction == 0: relative_tile_position[1] = -1

				try:
					# We check if the tile is in range, and if not, just go to the exception
					if self.player.position[0] + relative_tile_position[0] < 0 or \
							self.player.position[1] + relative_tile_position[1] < 0:
						raise IndexError

					tile = self.tilemap[self.player.position[0] + relative_tile_position[0]][self.player.position[1] \
					        + relative_tile_position[1]]
					# If the action of this tile is None, we just ignore it
					if tile.action is not None:
						tile.action()

				except IndexError:
					# If any error from tile number occurs, we just skip it, as it is the intended way
					pass

			self.do_movement(self.keystroke)

			# Keeping track of the time to execution
			self._last_frame_executed = time.time()


	def _quit(self):
		confirm = input("Do you want to leave ? (y/n): ")
		if confirm.lower() == "y":
			sys.exit(0)


	def do_movement(self, keystroke):
		# Movement
		old_player_pos = self.player.position.copy()
		if keystroke == self.controls[0]:
			self.player.position[0] -= 1
			self.player.current_direction = 1
		elif keystroke == self.controls[2]:
			self.player.position[0] += 1
			self.player.current_direction = 2
		elif keystroke == self.controls[1]:
			self.player.position[1] -= 1
			self.player.current_direction = 0
		elif keystroke == self.controls[3]:
			self.player.position[1] += 1
			self.player.current_direction = 3

		# Checking if the list is not out of bounds
		if self.player.position[0] < 0:
			self.player.position[0] = 0
		elif self.player.position[0] >= len(self.tilemap):
			self.player.position[0] = len(self.tilemap) - 1
		elif self.player.position[1] < 0:
			self.player.position[1] = 0
		elif self.player.position[1] >= len(self.tilemap[0]):
			self.player.position[1] = len(self.tilemap[0]) - 1

		# Remembering the tile on which the player is on
		tile = self.tilemap[self.player.position[0]][self.player.position[1]]
		# Resetting to last position if tile in front is a collider
		if tile.collision is True and self.noclip is False:
			self.player.position = old_player_pos.copy()
		# Otherwise, calling the walk_action of the tile the player is on (if it is not None)
		else:
			if tile.walk_action is not None and old_player_pos != self.player.position:
				tile.walk_action()


	def display_controls(self):
		controls_text_indicator = "Movement :\n"
		controls_text_indicator += f"      ____ \n     ||{self.controls[0].upper()} ||\n     ||__||\n     |/__\|\n" \
		                           f" ____ ____ ____\n||{self.controls[1].upper()} |||{self.controls[2].upper()}" \
		                           f" |||{self.controls[3].upper()} ||\n" \
		                           "||__|||__|||__||\n|/__\|/__\|/__\|"
		controls_text_indicator += "\n" * 3 + "Interact :\n"
		controls_text_indicator += f" ____ \n||{self.controls[4].upper()} ||\n||__||\n|/__\|"

		print("\n" * max(self.playable_area[0] - len(controls_text_indicator.split("\n")) - 2, 0),
		      controls_text_indicator, "\n" * 2, sep="")
		getch()


	def render_tiles(self, force_no_color: bool = False, display_inventory: bool = True):
		"""
		Main rendering function, allows to render all the tiles in the tilemap and display them on
		the screen. It also displays the player and the inventory.
		"""
		final_str = "" + Fore.WHITE
		# Fetching every coordinate in the displayed range
		for x in range(self.player.position[0] - (self.playable_area[0] // 2), (self.playable_area[0] // 2) + self.player.position[0]):
			for y in range(self.player.position[1] - (self.playable_area[1] // 2), (self.playable_area[1] // 2) + self.player.position[1]):

				# Checking if the tile is in the playable area, and if not, displaying 3 spaces
				if x < 0 or y < 0 or x >= len(self.tilemap) or y >= len(self.tilemap[0]):
					final_str += "   " + Back.RESET

				# If the current coords aren't those of a player
				elif x != self.player.position[0] or y != self.player.position[1]:

					# If this is a regular tile, we display it 3 times
					if self.tilemap[x][y].position == 0:
						final_str += self.tilemap[x][y].color + self.tilemap[x][y].name * 3 + Back.RESET

					# Otherwise, we work out where to place it
					elif self.tilemap[x][y].position == 1:  # Left
						try:
							final_str += self.tilemap[x][y].color + self.tilemap[x][y].name + self.tilemap[x][y + 1].color + \
							             self.tilemap[x][y + 1].name * 2 + Back.RESET
						except IndexError:
							final_str += self.tilemap[x][y].color + self.tilemap[x][y].name + " " * 2 + Back.RESET

					elif self.tilemap[x][y].position == 2:  # Center
						try:
							final_str += self.tilemap[x][y - 1].color + self.tilemap[x][y - 1].name\
							             + self.tilemap[x][y].color + self.tilemap[x][y].name \
							             + self.tilemap[x][y - 1].color + self.tilemap[x][y - 1].name + Back.RESET
						except IndexError:
							try:
								try:
									final_str += self.tilemap[x + 1][y].color + self.tilemap[x + 1][y].name\
							             + self.tilemap[x][y].color + self.tilemap[x][y].name \
							             + self.tilemap[x + 1][y].color + self.tilemap[x + 1][y].name + Back.RESET
								except IndexError:
									final_str += self.tilemap[x - 1][y].color + self.tilemap[x - 1][y].name\
							             + self.tilemap[x][y].color + self.tilemap[x][y].name \
							             + self.tilemap[x - 1][y].color + self.tilemap[x - 1][y].name + Back.RESET
							except IndexError:
								final_str += self.tilemap[x][y].color + " " + self.tilemap[x][y].name + " " + Back.RESET

					elif self.tilemap[x][y].position == 3:  # Right
						try:
							final_str += self.tilemap[x][y].color + self.tilemap[x][y].name + self.tilemap[x][y - 1].color + \
							             self.tilemap[x][y - 1].name * 2 + Back.RESET
						except IndexError:
							final_str += self.tilemap[x][y].color + self.tilemap[x][y].name + " " * 2 + Back.RESET

				else:  # Displaying player character
					# We look if the character usually below the player is a full tile
					if self.tilemap[x][y].position == 0:
						# If so, we know the tile to display below
						char_around = self.tilemap[x][y].name
						color_around = self.tilemap[x][y].color

					else:
						# We look for every character around him, and we determine which is the most used
						# while skipping those who are not full
						chars_around = ""
						colors_around = []
						for t in (-1, 1):
							try:
								if self.tilemap[x][y + t].position == 0:
									chars_around += self.tilemap[x][y + t].name
									colors_around.append(self.tilemap[x][y + t].color)
							except IndexError:
								pass
							try:
								if self.tilemap[x + t][y].position == 0:
									chars_around += self.tilemap[x + t][y].name
									colors_around.append(self.tilemap[x + t][y].color)
							except IndexError:
								pass
						if chars_around == "":
							chars_around = "-"
						if colors_around == "":
							colors_around = Back.BLACK

						# We select the character which is the most present around
						char_around = max(set(chars_around), key=chars_around.count)
						color_around = max(set(colors_around), key=colors_around.count)
						del chars_around
						del colors_around

					final_str += color_around + char_around + Style.BRIGHT + self.player.direction_characters[
						self.player.current_direction] + Style.RESET_ALL + color_around + char_around + Back.RESET
					del char_around
					del color_around

			final_str += "\n" + Style.RESET_ALL


		# Inventory display
		if display_inventory:
			final_str = final_str.split("\n")
			for i, element in enumerate(self.inventory.values()):
				final_str[i + 2] += "\t" + Style.RESET_ALL + element.name + " : " + str(element.value)
			final_str = "\n".join(final_str)


		if force_no_color:
			for style in ("BLACK", "RED", "GREEN", "YELLOW", "BLUE", "MAGENTA", "CYAN", "WHITE", "RESET"):
				for element in (Back, Fore):
					final_str = final_str.replace(getattr(element, style), "")
			for style in ("BRIGHT", "RESET_ALL", "DIM", "NORMAL"):
				final_str = final_str.replace(getattr(Style, style), "")

		print(final_str)


if __name__ == '__main__':
	# Creating the main characters
	plain_char = Char("▓", color=Back.GREEN)
	semi_plain_char = Char("▒", position=2, color=Back.YELLOW)


	# Creating a callback function for one character's action
	def hello():
		display_text("Hello !")


	# Creating a function to update one of the inventory items
	def is_low_health(value):
		if app.inventory["health"].value < 5:
			display_text(Fore.RED + "Low health !" + Style.RESET_ALL, slowness_multiplier=0.5, do_getch=False)
		elif app.inventory["health"].value <= 0:
			sys.exit(0)
		return value


	# Creating the main class and feeding it the tilemap, along with other arguments
	app = UnicodeEngine_RPG(
		tilemap=[
			[plain_char, plain_char, plain_char, plain_char, plain_char],
			[plain_char, plain_char, plain_char, plain_char, plain_char],
			[plain_char, plain_char.copy().set_walk_action(hello),
			 semi_plain_char.copy().set_collision(True).set_action(hello), plain_char, plain_char],
			[plain_char, plain_char, plain_char, plain_char, plain_char],
			[plain_char, plain_char, plain_char, plain_char, plain_char]
		],
		player=Player([0, 0]),
		playable_area=(8, 8),
		controls="zqsdf",  # AZERTY controls, you can change that
		force_monochrome=False,
		inventory={
			"health": InventoryItem("Health", 15, is_low_health)
		}
	)


	# Creating an update function
	def update(dt: float):
		app.inventory["health"].update_value(app.inventory["health"].value - dt)


	# Finally running the app
	app.run(update)
