"""
This file is part of the Tetris project.

Written 2023 by Maarten Tromp <maarten@geekabit.nl>


Website
-------
https://www.geekabit.nl/projects/tetris-window/


License
-------
CC0 (no copyright, public domain)
The person who associated a work with this deed has dedicated the work to the public domain by
waiving all of his or her rights to the work worldwide under copyright law, including all related
and neighbouring rights, to the extent allowed by law. You can copy, modify, distribute and perform
the work, even for commercial purposes, all without asking permission.
https://creativecommons.org/share-your-work/public-domain/cc0/
This file is part of the Tetris project.
"""


# imports
import random
import json
import logging


class Tetromino:
	"""
	Tetromino class. It holds all relevant data on a tetromino.
	"""


	def __init__(self, random_seed):
		"""
		Initialize tetromino class.

		Parameters
		----------
		random_seed: int
			Value to seed to random function to have repeatable games.
		"""

		# public variables
		self.coordinates = None
		self.shape = None
		self.rotation = None

		# private variables
		self._random_seed = random_seed
		self._random_bag = []

		# init
		self.new()


	def new(self):
		"""
		Generate a random Tetromino using 7-bag random.

		Parameters
		----------
		none

		Returns
		-------
		none
		"""

		if len(self._random_bag) == 0:
			random.seed(self._random_seed)
			self._random_seed += 1
			self._random_bag = random.sample(range(7), 7)
			logging.debug("generated random bag: %s", self._random_bag)

		# I and O spawn in the middle, the others spawn 1 to the left.
		# Playfield ends on row 19, so spawn on row 20. But first line of tetromino is always empty.
		self.coordinates = (3, 19)
		self.shape = self._random_bag.pop()
		self.rotation = 0 # always spawn in horizontal position


class TetrisGame:
	"""
	Tetris game class. It holds all game logic and pices.
	"""

	# constants
	directions = ("left", "right", "down")
	rotations = ("cw", "ccw")
	wall_colour = (31, 31, 31)
	shapes = (
		((4, 5, 6, 7), (1, 5, 9, 13), (8, 9, 10, 11), (2, 6, 10, 14)), # I
		((5, 6, 9, 10),), # O
		((3, 4, 5, 6), (1, 4, 7, 8), (2, 3, 4, 5), (0, 1, 4, 7)), # J
		((3, 4, 5, 8), (1, 2, 4, 7), (0, 3, 4, 5), (1, 4, 6, 7)), # L
		((3, 4, 7, 8), (2, 4, 5, 7), (0, 1, 4, 5), (1, 3, 4, 6)), # S
		((3, 4, 5, 7), (1, 4, 5, 7), (1, 3, 4, 5), (1, 3, 4, 7)), # T
		((4, 5, 6, 7), (1, 4, 5, 8), (1, 2, 3, 4), (0, 3, 4, 7))) # Z
	colours = (
		(  0, 255, 255), # I cyan
		(255, 255,   0), # O yellow
		(  0,  0,  255), # J blue
		(255, 170,   0), # L orange
		(  0, 255,   0), # S green
		(153,   0, 255), # T purple
		(255,   0,   0)) # Z red
	wallkick_i = (
		((0, 0), (-2, 0), ( 1, 0), (-2, -1), ( 1,  2)), # 0 cw
		((0, 0), (-1, 0), ( 2, 0), (-1,  2), ( 2, -1)), # 0 ccw
		((0, 0), (-1, 0), ( 2, 0), (-1,  2), ( 2, -1)), # 1 cw
		((0, 0), ( 2, 0), (-1, 0), ( 2,  1), (-1, -2)), # 1 ccw
		((0, 0), ( 2, 0), (-1, 0), ( 2,  1), (-1, -2)), # 2 cw
		((0, 0), ( 1, 0), (-2, 0), ( 1, -2), (-2,  1)), # 2 ccw
		((0, 0), ( 1, 0), (-2, 0), ( 1, -2), (-2,  1)), # 3 cw
		((0, 0), (-2, 0), ( 1, 0), (-2, -1), ( 1,  2))) # 3 ccw
	wallkick_o = (((0, 0),),)
	wallkick_jltsz = (
		((0, 0), (-1, 0), (-1,  1), (0, -2), (-1, -2)), # 0 cw
		((0, 0), ( 1, 0), ( 1,  1), (0, -2), ( 1, -2)), # 0 ccw
		((0, 0), ( 1, 0), ( 1, -1), (0,  2), ( 1,  2)), # 1 cw
		((0, 0), ( 1, 0), ( 1, -1), (0,  2), ( 1,  2)), # 1 ccw
		((0, 0), ( 1, 0), ( 1,  1), (0, -2), ( 1, -2)), # 2 cw
		((0, 0), (-1, 0), (-1,  1), (0, -2), (-1, -2)), # 2 ccw
		((0, 0), (-1, 0), (-1, -1), (0,  2), (-1,  2)), # 3 cw
		((0, 0), (-1, 0), (-1, -1), (0,  2), (-1,  2))) # 3 ccw
	lines_to_points = (1, 3, 5, 8)
	points_to_level = (5, 15, 30, 50, 75, 105, 140)
	level_to_gravity_delay = (10, 8, 6, 5, 4, 3, 2, 1)
	lock_delay = 5
	sounds = ("move", "rotate", "line-cleared", "level-up", "drop", "game-over")


	def __init__(self, screen, players, random_seed):
		"""
		Initialize all Tetris variables. Clear playfield.

		Parameters
		----------
		screen: screen object
		player: list of player objects
			The list should contain exactly 1 player.
		"""

		assert len(players) == 1

		# public variables
		self.save = False

		# private variables
		self._screen = screen
		self._player = players[0]
		self._playfield = [] # 2 dimensional list of RGB values
		self._tetromino = Tetromino(random_seed)
		self._dirty = True
		self._sound = ""
		self._points = 0
		self._landed = False
		self._full_lines = []
		self._delay_counter = 0
		self._gravity_delay = self.level_to_gravity_delay[0]
		self._finished = False

		# TODO: states: new_tetromino -> falling -> landed -> locked -> remove animation

		logging.debug("starting Tetris game for player %s (%s)", self._player.name, self._player.id)

		# init
		self._screen.clear()
		for playfield_y in range(self._screen.height):
			self._playfield.append([])
			for _ in range(self._screen.width):
				self._playfield[playfield_y].append(self._screen.background_colour)

		self._player.send(json.dumps({"music" : "korobeiniki"}))
		self._player.send(json.dumps({"sounds" : self.sounds}))


	def _is_position_possible(self, desired_coordinates = None, desired_shape = None,
			desired_rotation = None):
		"""
		Test if desired move is possible.

		Parameters
		----------
		desired_coordinates: tuple or list of 2 numbers
		desired_shape: number [0..6]
		desired_rotation: number [0..3]

		Returns
		-------
		bool
			True if position is possible, False otherwise.
		"""

		# It is not possible to use "self" in default parameters, so work around that.
		if desired_coordinates is None:
			desired_coordinates = self._tetromino.coordinates
		if desired_shape is None:
			desired_shape = self._tetromino.shape
		if desired_rotation is None:
			desired_rotation = self._tetromino.rotation

		# allow for spawning and attempts to move tetromino slightly outside playfield
		assert len(desired_coordinates) == 2, f"desired_coordinates: {desired_coordinates}"
		(desired_x, desired_y) = desired_coordinates
		assert -3 <= desired_x < self._screen.width + 3, f"desired_x: {desired_x}"
		assert -3 <= desired_y < self._screen.height + 3, f"desired_y: {desired_y}"
		assert 0 <= desired_shape < 7, f"desired_shape: {desired_shape}"
		assert 0 <= desired_rotation < 4, f"desired_rotation: {desired_rotation}"

		desired_box_width = 3
		if desired_shape in (0, 1): # I or O
			desired_box_width = 4

		for val in self.shapes[desired_shape][desired_rotation]:
			box_x = val % desired_box_width
			box_y = int(val / desired_box_width)
			playfield_x = desired_x + box_x
			playfield_y = desired_y + box_y
			# check for out of playfield
			if not (0 <= playfield_x < self._screen.width and
					0 <= playfield_y < self._screen.height):
				return False
			# check for collision
			if self._playfield[playfield_y][playfield_x] != self._screen.background_colour:
				return False
		return True


	def _move(self, direction):
		"""
		Attempt to move tetromino one position in desired direction. If move is not possible nothing
		happens.

		Parameters
		----------
		direction: string
			One of: left, right, down.

		Returns
		-------
		bool
			True if move is succesful, False otherwise.
		"""

		assert direction in self.directions, f"direction: {direction}"

		# get desired location
		(desired_x, desired_y) = self._tetromino.coordinates
		if direction == "left":
			desired_x -= 1
		elif direction == "right":
			desired_x += 1
		elif direction in ("down"):
			desired_y -= 1
		desired_coordinates = (desired_x, desired_y)

		if self._is_position_possible(desired_coordinates):
			# do move tetromino
			self._tetromino.coordinates = desired_coordinates
			self._dirty = True
			if direction in ("left", "right"):
				self._sound = "move"
			self._is_landed()
			self._delay_counter = 0
			return True

		return False


	def _rotate(self, rotation):
		"""
		Attempt to rotate tetromino one quarter turn in desired rotation. If rotation is not possible,
		try wall kicks. If no rotation / move is possible, nothing happens.

		Parameters
		----------
		rotation: string
			One of: cw, ccw.

		Returns
		-------
		bool
			True if rotation is succesful, False otherwise.
		"""

		assert rotation in self.rotations, f"rotation: {rotation}"

		# get desired location
		(desired_x, desired_y) = self._tetromino.coordinates
		wallkick_pos = 2 * self._tetromino.rotation
		possible_rotations = len(self.shapes[self._tetromino.shape])
		if rotation == "cw":
			desired_rotation = (self._tetromino.rotation + 1) % possible_rotations
		else: # ccw
			desired_rotation = (self._tetromino.rotation - 1) % possible_rotations
			wallkick_pos += 1

		# wall kicks
		if self._tetromino.shape == 0: # I
			wallkick_table = self.wallkick_i
		elif self._tetromino.shape == 1: # O
			wallkick_table = self.wallkick_o
			wallkick_pos = 0
		else:
			wallkick_table = self.wallkick_jltsz
		for kick in wallkick_table[wallkick_pos]:
			kick_x = kick[0]
			kick_y = kick[1]
			desired_x += kick_x
			desired_y += kick_y
			desired_coordinates = (desired_x, desired_y)
			if self._is_position_possible(desired_coordinates, desired_rotation = desired_rotation):
				# do rotate tetromino
				self._tetromino.coordinates = desired_coordinates
				self._tetromino.rotation = desired_rotation
				self._dirty = True
				self._sound = "rotate"
				self._is_landed()
				self._delay_counter = 0
				return True
		return False


	def _hard_drop(self):
		"""
		Move tetromino fully down and lock it.

		Parameters
		----------
		none

		Returns
		-------
		none
		"""

		# move down until no longer possible
		while self._move("down"):
			pass

		# no lock delay for hard drop
		self._lock()


	def _lock(self):
		"""
		Lock tetromino in place. It will become part of the playfield.
		A new tetromino will be generated.

		Parameters
		----------
		none

		Returns
		-------
		none
		"""

		# copy tetromino into playfield
		box_width = 3
		if self._tetromino.shape in (0, 1): # I or O
			box_width = 4
		for val in self.shapes[self._tetromino.shape][self._tetromino.rotation]:
			box_x = val % box_width
			box_y = int(val / box_width)
			(tetromino_x, tetromino_y) = self._tetromino.coordinates
			playfield_x = tetromino_x + box_x
			playfield_y = tetromino_y + box_y
			assert 0 <= playfield_x < self._screen.width, f"playfield_x: {playfield_x}, box_x: {box_x}, tetromino_x: {tetromino_x}"
			assert 0 <= playfield_y < self._screen.height, f"playfield_y: {playfield_y}, box_y: {box_y}, tetromino_y: {tetromino_y}"
			self._playfield[playfield_y][playfield_x] = self.colours[self._tetromino.shape]
		self._dirty = True
		#self._sound = "lock" # gameboy tetris does not have this sound effect

		self._find_full_lines()
		if self._full_lines:
			self._remove_full_lines()
		self._tetromino.new()
		self._is_landed()
		self._is_finished()


	def _find_full_lines(self):
		"""
		Find if there are any full lines.

		Parameters
		----------
		none

		Returns
		-------
		none
		"""

		# find full line(s)
		for i, row in enumerate(self._playfield):
			if not self._screen.background_colour in row:
				self._full_lines.append(i)

		# reverse order because higher lines move down when you remove a low line
		self._full_lines.reverse()


	def _remove_full_lines(self):
		"""
		Remove full lines. Update points and level.

		Parameters
		----------
		none

		Returns
		-------
		none
		"""

		assert len(self._full_lines) > 0

		# remove lines, add new ones on top of playfield
		for i in self._full_lines:
			del self._playfield[i]
			self._playfield.insert(self._screen.height - 1, [self._screen.background_colour for _ in range(self._screen.width)])
		self._sound = "line-cleared"

		# calculate old level
		old_level = 7
		for i, points in enumerate(self.points_to_level):
			if self._points < points:
				old_level = i
				break

		# calculate points
		lines_cleared = len(self._full_lines)
		self._full_lines = []
		self._points += self.lines_to_points[lines_cleared - 1]

		# calculate current level
		level = 7
		for i, points in enumerate(self.points_to_level):
			if self._points < points:
				level = i
				break

		# check for level up
		if level > old_level:
			self._sound = "level-up"
			self._gravity_delay = self.level_to_gravity_delay[level]

		# check for game worth saving
		if points > 10:
			self.save = True

		logging.debug("lines_cleared: %s, points: %s, level: %s", lines_cleared, self._points, level + 1)



	def _is_landed(self):
		"""
		Test if a tetromino has landed i.e. cannot go down any further.

		Parameters
		----------
		none

		Returns
		-------
		none
		"""

		# get desired location
		self._landed = False
		(desired_x, desired_y) = self._tetromino.coordinates
		desired_y -= 1
		desired_coordinates = (desired_x, desired_y)
		if not self._is_position_possible(desired_coordinates):
			self._landed = True
			self._sound = "drop"



	def _is_finished(self):
		"""
		Test for top-out condition. A new Tetromino has just been spawned, test if that position
		if even possible.

		Parameters
		----------
		none

		Returns
		-------
		none
		"""

		if not self._is_position_possible():
			logging.debug("topping out, game finished")
			self._finished = True
			self._sound = "game-over"

	def _handle_buttons(self):
		button_state = self._player.get_button_state()
		if button_state["a"] > 0:
			# rotate tetromino counter-clockwise
			self._rotate("ccw")
		elif button_state["b"] > 0:
			# rotate tetromino clockwise
			self._rotate("cw")
		elif button_state["left"] > 0:
			# move tetromino left
			self._move("left")
		elif button_state["right"] > 0:
			# move tetromino right
			self._move("right")
		elif button_state["down"] > 0:
			# soft-drop tetromino
			self._move("down")
		elif button_state["up"] == 1:
			# hard-drop tetromino
			self._hard_drop()


	def update(self):
		"""
		Update game state. This function is called at a regular interval. Handle timed actions and user
		actions.

		Parameters
		----------
		none

		Returns
		-------
		bool
			True if game should continue, False on game-over.
		"""

		# update tetromino with new controller state
		self._handle_buttons()

		# gravity and locking
		if self._landed:
			# use lock delay
			if self._delay_counter >= self.lock_delay:
				self._lock()
				self._delay_counter = 0
		else:
			# use gravity delay
			if self._delay_counter >= self._gravity_delay:
				self._move("down")
				self._delay_counter = 0
		self._delay_counter += 1

		if not self._dirty:
			# nothing happened during update
			return True

		# update screen
		self._update_screen()
		self._dirty = False

		# trigger sound effects
		if self._sound:
			logging.debug("sound: %s", self._sound)
			self._player.send(json.dumps({"sound" : self._sound}))
			self._sound = ""

		# check if game over
		if self._finished:
			# stop music
			self._player.send(json.dumps({"music" : False}))
			return False

		return True


	def _update_screen(self):
		"""
		Draw playfield and active tetromino on screen.

		Parameters
		----------
		none

		Returns
		-------
		none
		"""

		self._screen.clear()

		# draw spawn line
		for playfield_x in range(self._screen.width):
			playfield_y = 20
			screen_x = playfield_x
			screen_y = self._screen.height - 1 - playfield_y
			self._screen.set_pixel((screen_x, screen_y), self.wall_colour)

		# draw playfield
		for playfield_x in range(self._screen.width):
			for playfield_y in range (self._screen.height):
				pixel = self._playfield[playfield_y][playfield_x]
				if not pixel == self._screen.background_colour:
					screen_x = playfield_x
					screen_y = self._screen.height - 1 - playfield_y
					self._screen.set_pixel((screen_x, screen_y), self._playfield[playfield_y][playfield_x])

		# draw tetromino
		box_width = 3
		if self._tetromino.shape in (0, 1): # I or O
			box_width = 4
		for val in self.shapes[self._tetromino.shape][self._tetromino.rotation]:
			box_x = val % box_width
			box_y = int(val / box_width)
			(tetromino_x, tetromino_y) = self._tetromino.coordinates
			screen_x = tetromino_x + box_x
			screen_y = self._screen.height - 1 - (tetromino_y + box_y)
			screen_coordinates = (screen_x, screen_y)
			self._screen.set_pixel(screen_coordinates, self.colours[self._tetromino.shape])
