########################################################################
#	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 panda3d.core import BitMask32, Vec3
from panda3d.bullet import BulletCharacterControllerNode, BulletRigidBodyNode
from panda3d.bullet import BulletCapsuleShape, ZUp
from direct.actor.Actor import Actor
from panda3d.ai import AIWorld, AICharacter

from core.db import NPCs
from core.worldMouse import NPCMouse
from core.models import SelectedNpcModel, StatsModel


class NPC:
	def __init__(self, spawn, world, worldNP, aiWorld):
		"""NPC

		@param spawn: Object that contains data about this NPC spawn.
		@type spawn: db.SpawnData

		@param world: 
		@type world: BulletWorld

		@param worldNP: 
		@type worldNP: NodePath

		@param aiWorld:
		@type aiWorld: AIWorld
		"""
		self._id = spawn.id # unique spawn id, not to confuse with npc id.
		self._world = world
		self._worldNP = worldNP
		self._aiWorld = aiWorld

		self._data = NPCs[spawn.characterId] # default data
		self._spawn = spawn

		self._healing = False
		self._healingRate = 1 # in seconds
		self._healingUnit = 1 # in healing units

		self.setup()

	def __hash__(self): return self._id

	def __lt__(self, other):
		return not other < self._id

	def isDead(self):
		return not bool(self.characterNP)

	def _healthChanged(self, value):
		if value <= 0:
			self.die()
		elif not self._healing and value < self.stats.health.max: # heal
			self._healing = True
			taskMgr.doMethodLater(self._healingRate, self._heal, '{}_heal'.format(self._id))

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

	def resetStats(self):
		self._stats = StatsModel(self._data.stats)

	@property
	def stats(self): return self._stats

	def die(self):
		self.destroy()
		if self._spawn.respawnTime:
			# set respawn time to 0 to not respawn.
			taskMgr.doMethodLater(self._spawn.respawnTime, self.respawn, 'respawn')

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

	@property
	def id(self):
		# Returns unique NPC spawn id
		return self._id

	@property
	def data(self):
		# Returns db.NPCData model
		return self._data

	@property
	def spawnData(self):
		# Returns db.SpawnData model
		return self._spawn

	def _distanceTo(self, pos): # vec3 pos
		# This doesn't consider height or Z axis.
		diffVec = pos - self.characterNP.getPos()
		diffVecXY = diffVec.getXy()
		return diffVecXY.length()

	def distanceToPlayer(self):
		return self._distanceTo(base.world.player.characterNP.getPos())

	def distanceToSpawnPoint(self):
		return self._distanceTo(Vec3(*self.spawnData.pos))

	def destroy(self):
		self.stats.health.valueChanged.disconnect(self._healthChanged)

		self._aiWorld.removeAiChar("npc_{0}".format(self._id))
		self.actorNP.cleanup()
		self.actorNP.removeNode()
		self.characterNP.removeNode()
		self._world.remove(self.characterCont)

		self.actorNP = None
		self.characterNP = None
		self.characterCont = None

	def setup(self):
		self.resetStats()

		h = 0.1
		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, "npc_{0}".format(self._id))
		self.characterCont.setGravity(18.0)
		self.characterCont.setMaxSlope(0.5)
		self.characterNP = self._worldNP.attachNewNode(self.characterCont)
		self.characterNP.setPos(*self.spawnData.pos)
		self.characterNP.setH(self.spawnData.orientation)
		self.characterNP.setCollideMask(BitMask32.allOn())
		self._world.attach(self.characterCont)

		# Character model
		self.actorNP = Actor(self._data.filePath)
		self.actorNP.setTag('spawnId', str(self.spawnData.id))
		self.actorNP.setTag('npcId', str(self.spawnData.characterId))
		# TODO animations
		#self.actorNP = Actor(
		#	"self._data.file,
		#	self._data.animations)
		self.actorNP.setPlayRate(1.1, 'run')
		self.actorNP.reparentTo(self.characterNP)
		self.actorNP.setScale(0.3048) # 1ft = 0.3048m

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

		# Setup AI
		self.AIchar = AICharacter("spawnId_{0}".format(self._id), self.characterNP, 100, 3, 3)
		self._aiWorld.addAiChar(self.AIchar)

		AIbehaviors = self.AIchar.getAiBehaviors()
		# evade (NodePath target_object, double panic_distance, double relax_distance, float evade_wt)
		# wander (double wander_radius, int flag, double aoe, float wander_weight)
		# pursue (NodePath target_object, float pursue_wt)
		# 
		AIbehaviors.evade(base.world.player.characterNP, 10, 25, 10)
		AIbehaviors.wander(25, 0, 45, 5)
		#AIbehaviors.pursue(base.world.player.characterNP, 2)

		# Connections
		self.stats.health.valueChanged.connect(self._healthChanged)

	def addSelectPlane(self):
		self.groundPlane = loader.loadModel('assets/other/select_plane.egg') #TODO use AssetsPath
		self.groundPlane.reparentTo(self.actorNP)
		self.groundPlane.setPos(0, 0, 0)
		self.groundPlane.setScale(2)

	def removeSelectPlane(self):
		self.groundPlane.removeNode()


class NPCsManager:
	def __init__(self, world, worldNP):
		"""
		@param world:
		@type world: BulletWorld

		@param worldNP:
		@type worldNP: NodePath
		"""
		self._world = world
		self._worldNP = worldNP
		self._player = None

		self._spawnData = None # List with db.SpawnData's from assets/maps/{map_name}/spawns.json

		self._np = self._worldNP.attachNewNode(BulletRigidBodyNode('NPCs'))
		self._AIworld = AIWorld(self._worldNP)
		self._spawns = {} # spawned npcs

		self._selectModel = SelectedNpcModel()
		self.mouseHandler = NPCMouse(self._np, self._selectModel)

		taskMgr.add(self.update, 'updateNPCs')

	@property
	def selectedNpcModel(self): return self._selectModel

	@property
	def node(self): return self._np

	def clear(self):
		# Remove all npcs from the world.
		# TODO Remove objects
		# ..
		self._spawnData = None

		for spawnId in self._spawns:
			# TODO make sure everything is proper deleted. (same happends in self.update)
			self._spawns[spawnId].destroy()
		self._spawns.clear()

	def setPlayer(self, player):
		self._player = player

	def setSpawnData(self, spawns):
		self.clear()
		self._spawnData = spawns

		for spawn in self._spawnData: self.spawn(spawn)

	def spawn(self, spawn): # spawn
		self._spawns.update({spawn.id : NPC(spawn, self._world, self._np, self._AIworld)})

	def getSpawn(self, spawnId): # spawn id
		return self._spawns.get(str(spawnId))

	def distanceBetween(self, pos, pos2):
		# This doesn't consider height or Z axis.
		diffVec = pos - pos2
		diffVecXY = diffVec.getXy()
		return diffVecXY.length()

	def update(self, task):
		if self._player:
			self._AIworld.update()

			if base.world.player.isMoving:
				# Remove out of range
				for spawn in self._spawnData:
					distance = self.distanceBetween(
						Vec3(*spawn.pos),
						base.world.player.characterNP.getPos()
					)

					if distance > 100: # Out of range
						if spawn.id in self._spawns:
							# TODO make sure everything is proper deleted.
							self._spawns.pop(spawn.id).destroy()

					elif distance < 80: # In range
						if spawn.id not in self._spawns:
							self._spawns.update({spawn.id : NPC(spawn, self._world, self._np, self._AIworld)})

		return task.cont
