########################################################################
#	Hello Worlds - Libre 3D RPG game.
#	Copyright (C) 2020  CYBERDEViL
#
#	This file is part of Hello Worlds.
#
#	Hello Worlds is free software: you can redistribute it and/or modify
#	it under the terms of the GNU General Public License as published by
#	the Free Software Foundation, either version 3 of the License, or
#	(at your option) any later version.
#
#	Hello Worlds is distributed in the hope that it will be useful,
#	but WITHOUT ANY WARRANTY; without even the implied warranty of
#	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#	GNU General Public License for more details.
#
#	You should have received a copy of the GNU General Public License
#	along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
########################################################################


from core.models import PlayerStatsModel, StatsModel, SpellsModel
from core.signals import Signal

from core.db import Spells, Fractions
from core.spells import BasicCast

class CharacterModel:
	def __init__(self, characterData, spawnData):
		""" Data model for a character.

		@param characterData:
		@type characterData: core.db.CharacterData

		@param spawnData:
		@type spawnData: core.db.GenericSpawnData
		"""
		self._characterData = characterData
		self._spawnData = spawnData

		self._spells = SpellsModel(characterData.spells)
		self.resetStats()

	""" Expose dynamic attributes
	"""
	@property
	def spells(self):
		""" Returns the character it's SpellsModel
		@rtype: core.models.SpellsModel
		@return: Character it's current spells
		"""
		return self._spells

	@property
	def stats(self):
		""" Returns the character it's StatsModel
		@rtype: core.models.StatsModel
		@return: Current stats
		"""
		return self._stats

	def resetStats(self):
		""" Resets the stats.
		"""
		self._stats = StatsModel(self._characterData.stats)

	""" Expose CharacterData
	"""
	@property
	def id(self):
		""" Returns the character it's ID.
		@rtype: int
		@return: Character ID
		"""
		return self._characterData.id

	@property
	def name(self):
		""" Returns the character it's name.
		@rtype: int
		@return: Character ID
		"""
		return self._characterData.name

	@property
	def file(self):
		""" Returns filename of the character it's model.
		@rtype: str
		@return: Filename (.egg)
		"""
		return self._characterData.file

	@property
	def filePath(self):
		""" Returns the full filepath to the model file (.egg).
		@rtype: str
		@return: Full filepath to model (.egg)
		"""
		return self._characterData.filePath

	@property
	def speciesId(self):
		""" Returns character it's species ID.
		@rtype: int
		@return: The character it's species ID.
		"""
		return self._characterData.speciesId

	@property
	def fraction(self):
		""" Returns character it's fraction ID.
		@rtype: int
		@return: The character it's fraction ID.
		"""
		return self._characterData.fractionId

	@property
	def enemies(self):
		""" Returns character it's enemies fraction ID's.
		@rtype: int
		@return: The character it's enemies fraction ID's.
		"""
		fraction = Fractions[self._characterData.fractionId]
		return fraction.enemies

	""" Expose SpawnData
	"""
	@property
	def spawnData(self):
		""" Returns character it's spawn data.
		@rtype: core.db.GenericSpawnData
		@return: The character it's spawn data.
		"""
		return self._spawnData

class PlayerCharacterModel(CharacterModel):
	def __init__(self, characterData, spawnData):
		""" Player character data model
		@param characterData:
		@type characterData: core.db.PlayerData

		@param spawnData:
		@type spawnData: core.db.GenericSpawnData
		"""
		CharacterModel.__init__(self, characterData, spawnData)

	def resetStats(self):
		""" Resets the player it's stats.
		"""
		self._stats = PlayerStatsModel(self._characterData.stats)



# Panda3d
from panda3d.core import BitMask32, Vec3, lookAt

from panda3d.bullet import BulletCharacterControllerNode
from panda3d.bullet import BulletCapsuleShape, ZUp

from direct.actor.Actor import Actor

class CharacterProto:
	""" Bones of character, only collision mesh (no Actor).
	"""
	def __init__(self, world, worldNP, characterModel):
		"""
		@param world:
		@type world: panda3d.bullet.BulletWorld

		@param worldNP:
		@type worldNP: panda3d.core.NodePath

		@param characterModel:
		@type characterModel: core.character.CharacterModel
		"""
		## Emits spawn id
		self.hasDied = Signal(int)
		## Emits spawn id
		self.destroyed = Signal(int)

		self._world = world
		self._worldNP = worldNP

		self._characterData = characterModel

		self.crouching = False
		self.isMoving = False
		self.isCasting = False

		self._underAttackBy = []

		self._restoringHealth = False
		self._healingRate = 3 # in seconds
		self._healingUnit = 1 # in health units

		self._restoringEnergy = False
		self._energyRestoreRate = 2 # in seconds
		self._energyRestoreUnit = 3 # in energy units

		self._previousCharacterPos = Vec3()

	def canSee(self, other, angle=45):
		"""
		@param other: Other character to test against.
		@type other: core.character.Character

		@param angle: Maximum viewing angle.
		@type angle: int

		@rtype: bool
		@return: True if we can see the other character, False if not.
		"""
		quat = self.characterNP.getQuat()
		lookAt(quat, other.getGlobalPos() - self.getGlobalPos(), Vec3.up())
		diffHeading = quat.getHpr()[0] - self.getOrientation()
		if diffHeading > angle / 2 or diffHeading < -(angle / 2):
			return False
		return True

	def die(self):
		self.hasDied.emit(int(self.characterData.spawnData.id))
		self.destroy()

	def respawn(self, task):
		self.setup()
		return task.done

	def isDead(self):
		return bool(self.characterNP == None)

	def startCast(self, spellId, targetSpawnId=-1):
		""" Do some basic checks if we may cast this spell;
		if so start the process of casting the spell.

		@param spellId: Spell ID to cast.
		@type spellId: str

		@param targetSpawnId: Spawn id of the target
		@type targetSpawnId: str
		"""
		spell = self.characterData.spells[spellId]

		# Select method
		if spell.data.method == Spells.methods.basic:
			BasicCast(self, spell, targetSpawnId)

	def takeHit(self, spellId, targetSpawnId=-1):
		"""
		"""
		pass

	def underAttackBy(self, spawnId):
		"""
		@param spawnId: Character spawn id that is attacking
		@type spawnId: int
		"""
		if self.isDead(): return

		if spawnId not in self._underAttackBy:
			attacker = base.world.player
			if spawnId > 0:
				attacker = base.world.npcsManager.getSpawn(spawnId)
			if not attacker: return
			if attacker.isDead(): return

			print("[{} {}] underAttackBy [{} {}]".format(
				self.characterData.spawnData.id, self.characterData.name, spawnId, attacker.characterData.name))

			attacker.hasDied.connect(self._attackerDied)
			self._underAttackBy.append(spawnId)

	def _attackerDied(self, spawnId):
		if spawnId in self._underAttackBy:
			print("[{} {}] Attacker died, removing it from the list.".format(
				self.characterData.spawnData.id, self.characterData.name))
			self._underAttackBy.remove(spawnId)

			attacker = base.world.player
			if spawnId > 0:
				attacker = base.world.npcsManager.getSpawn(spawnId)
			if attacker:
				attacker.hasDied.disconnect(self._attackerDied)

	""" Auto health/energy restore
	"""
	def _healthChanged(self, value):
		if value <= 0:
			self.die()
		elif not self._restoringHealth and value < self.characterData.stats.health.max: # restore
			self._restoringHealth = True
			taskMgr.doMethodLater(self._healingRate, self._restoreHealth, '{}_restoreHealth'.format(self.characterData.spawnData.id))

	def _restoreHealth(self, task):
		if not self.isDead() and self.characterData.stats.health.value < self.characterData.stats.health.max:
			self.characterData.stats.health.value += self._healingUnit
			return task.again
		self._restoringHealth = False
		return task.done

	def _energyChanged(self, value):
		if not self._restoringEnergy and value < self.characterData.stats.energy.max: # restore
			self._restoringEnergy = True
			taskMgr.doMethodLater(self._energyRestoreRate, self._restoreEnergy, '{}_restoreEnergy'.format(self.characterData.spawnData.id))

	def _restoreEnergy(self, task):
		if not self.isDead() and self.characterData.stats.energy.value < self.characterData.stats.energy.max:
			self.characterData.stats.energy.value += self._energyRestoreUnit
			return task.again
		self._restoringEnergy = False
		return task.done

	@property
	def characterData(self):
		""" characterData
		@rtype: core.db.CharacterData
		@return: The character it's data.
		"""
		return self._characterData

	def destroy(self):
		""" Call this to destruct / remove the character from the world.
		"""
		self.destroyed.emit(int(self.characterData.spawnData.id))
		for spawnId in self._underAttackBy:
			attacker = base.world.player
			if spawnId > 0:
				attacker = base.world.npcsManager.getSpawn(spawnId)
			if attacker:
				attacker.hasDied.disconnect(self._attackerDied)
		self._underAttackBy.clear()

		self.characterData.stats.health.valueChanged.disconnect(self._healthChanged)
		self.characterData.stats.energy.valueChanged.disconnect(self._energyChanged)
		self.characterNP.removeNode()
		self._world.remove(self.characterCont) # (BulletWorld.remove())
		del self.characterNP
		del self.characterCont
		self.characterCont = None
		self.characterNP = None

		self.crouching = False
		self.isMoving = False
		self.isCasting = False

		self._restoringEnergy = False
		self._restoringHealth = False

		self._previousCharacterPos = Vec3()

	def setup(self):
		""" Call this to setup (re-init).
		"""
		self.characterData.resetStats()

		h = 0.6
		w = 0.3

		# BulletCapsuleShape(float radius, float height, BulletUpAxis up)
		shape = BulletCapsuleShape(w, h, ZUp)

		# BulletCharacterControllerNode(BulletShape shape, float step_height, str name)
		self.characterCont = BulletCharacterControllerNode(shape, 0.5, "character_{0}".format(self.characterData.spawnData.id))
		self.characterCont.setGravity(18.0)
		self.characterCont.setMaxSlope(0.5)
		self.characterNP = self._worldNP.attachNewNode(self.characterCont)
		self.characterNP.setPos(*self.characterData.spawnData.pos)
		self.characterNP.setH(self.characterData.spawnData.orientation)
		self.characterNP.setCollideMask(BitMask32.allOn())
		self._world.attach(self.characterCont)

		self.characterData.stats.health.valueChanged.connect(self._healthChanged)
		self.characterData.stats.energy.valueChanged.connect(self._energyChanged)

	def rotate(self, omega):
		""" Rotate the character.
		@param omega: 
		@type omega: float
		"""
		self.characterNP.setH(self.characterNP, omega)

	def rotateLeft(self, dt):
		""" Rotate the character left.
		@param dt: Delta-time
		@type dt: float
		"""
		omega = 120 * dt
		self.characterNP.setH(self.characterNP, omega)
		return omega

	def rotateRight(self, dt):
		""" Rotate the character right.
		@param dt: Delta-time
		@type dt: float
		"""
		omega = -120 * dt
		self.characterNP.setH(self.characterNP, omega)
		return omega

	def forward(self, dt, moveVec):
		""" Modifies the given Vec3 to a forward movement.
		@param dt: Delta-time
		@type dt: float

		@param moveVec: Vec3 to modify with dt.
		@type moveVec: panda3d.core.Vec3
		"""
		moveVec.setY(6 * dt)

	def backward(self, dt, moveVec):
		""" Modifies the given Vec3 to a backward movement.
		@param dt: Delta-time
		@type dt: float

		@param moveVec: Vec3 to modify with dt.
		@type moveVec: panda3d.core.Vec3
		"""
		moveVec.setY(-3 * dt)

	def shuffleLeft(self, dt, moveVec):
		""" Modifies the given Vec3 to shuffle left.
		@param dt: Delta-time
		@type dt: float

		@param moveVec: Vec3 to modify with dt.
		@type moveVec: panda3d.core.Vec3
		"""
		moveVec.setX(-3 * dt)

	def shuffleRight(self, dt, moveVec):
		""" Modifies the given Vec3 to shuffle right.
		@param dt: Delta-time
		@type dt: float

		@param moveVec: Vec3 to modify with dt.
		@type moveVec: panda3d.core.Vec3
		"""
		moveVec.setX(3 * dt)

	def setPos(self, moveVec):
		""" Applies the moveVec to the character.

		@param moveVec: Position modifier.
		@type moveVec: panda3d.core.Vec3
		"""
		self.characterNP.setPos(self.characterNP, moveVec)

	def setGlobalPos(self, pos):
		""" Sets new global position for the character.

		@param pos: New global position.
		@type pos: panda3d.core.Vec3
		"""
		self.characterNP.setPos(pos)

	def setOrientation(self, o):
		""" Sets new global orientation for the character.

		@param o: New global orientation.
		@type o: float
		"""
		self.characterNP.setH(o)

	def setGlobalX(self, x):
		""" Sets new global x position for the character.

		@param x: New x position.
		@type x: float
		"""
		self.characterNP.setX(x)

	def setGlobalY(self, y):
		""" Sets new global y position for the character.

		@param y: New y position.
		@type y: float
		"""
		self.characterNP.setY(y)

	def setGlobalZ(self, z):
		""" Sets new global z position for the character.

		@param z: New z position.
		@type z: float
		"""
		self.characterNP.setZ(z)

	def getGlobalPos(self):
		""" Returns global position of the character.

		@rtype: panda3d.core.Vec3
		@return: The character it's global position.
		"""
		return self.characterNP.getPos()

	def getOrientation(self):
		""" Returns global orientation of the character.

		@rtype: float
		@return: The character it's global orientation.
		"""
		return self.characterNP.getH()

	def updatePreviousPos(self): # TODO find better name for this
		""" Sets self.isMoving - TODO this needs to change.
		"""
		characterPos = self.characterNP.getPos()

		if characterPos[2] < -10:
			# reset pos (character is fallen of the map)
			self.characterNP.setPos(*self.characterData.spawnData.pos) # TODO create function for this, it doesnt belong here

		if characterPos != self._previousCharacterPos:
			self._previousCharacterPos = characterPos
			self.isMoving = True
		else: self.isMoving = False

	def doJump(self):
		""" Makes the character jump.
		"""
		if self.characterCont.isOnGround() and self.isMoving:
			self.characterCont.setMaxJumpHeight(1.25)
			self.characterCont.setJumpSpeed(5.6)
			self.characterCont.setFallSpeed(16)
			self.characterCont.doJump()

	def doCrouch(self):
		""" Should make the character crouch. TODO this doesn't work.
		"""
		self.crouching = not self.crouching
		#sz = self.crouching and 1.2 or 1.0
		# eh this does not work
		# https://www.panda3d.org/manual/?title=Bullet_Character_Controller#Crouching
		#self.characterNP.setScale(Vec3(1, 1, sz))
		#self.characterCont.getShape().setLocalScale(Vec3(1, 1, sz))

class Character(CharacterProto):
	""" This extends CharacterProto with a Actor (3d model/animations)
	"""
	def __init__(self, world, worldNP, characterModel):
		CharacterProto.__init__(self, world, worldNP, characterModel)

		self._animationSet = False

		self.setup()

	def destroy(self):
		CharacterProto.destroy(self)
		self.actorNP.cleanup()
		self.actorNP.removeNode()
		self._animationSet = False

	def setup(self):
		# Create character
		CharacterProto.setup(self)

		# Character model
		self.actorNP = Actor(
			self.characterData.filePath
		)
		#self.actorNP = Actor(
		#	"assets/characters/{0}".format(self.character.model.file),
		#	self.character.model.actions)

		self.actorNP.loop('idle')

		self.actorNP.setTag('spawnId', str(self.characterData.spawnData.id))
		self.actorNP.setTag('characterId', str(self.characterData.spawnData.characterId))

		self.actorNP.setPlayRate(1.1, 'run')
		self.actorNP.reparentTo(self.characterNP)
		self.actorNP.setScale(0.3048) # 1ft = 0.3048m
		self.actorNP.setH(180)

		# Model to collision mesh offset TODO make dynamic
		self.actorNP.setPos(0, 0, -0.55)

