-- (c)1dentity - part of Quest Runner custom mission framework
local Cron = require("lib/Cron")
local log, errorLog, trace = table.unpack(require("module/Log"))

local Spawner = {}
local worldNPCs = {}
local awaitingSpawnCallbacks = {}
local activeCommands = {}
local time = 0
local lastUpdate = 0
local UPDATE_INTERVAL = 1
local SPAWN_TIMEOUT = 5
function Spawner.init()
	ObserveBefore('PreventionSpawnSystem', 'SpawnRequestFinished', function(_, result)
		local reqId = result.requestID
		local req
		local reqOnSpawnIndex

		for i, awaitingReq in ipairs(awaitingSpawnCallbacks) do
			if awaitingReq.reqId == reqId then
				req = awaitingReq
				reqOnSpawnIndex = i
			end
		end

		if not req then
			log("Spawn request not found - ignore if that's police")
			return
		end

		local wnpc = worldNPCs[req.npcID]
		if not wnpc then
			errorLog("Spawned request doesn't match npc ID")
			return
		end

		for i, spawnedObject in ipairs(result.spawnedObjects) do
			if time - req.requestTime > SPAWN_TIMEOUT then
				Spawner.Despawn(spawnedObject)
				wnpc.isSpawning = false
			else
				-- if spawnedObject:IsA('NPCPuppet') then
				wnpc.ref = spawnedObject
				wnpc.entityID = spawnedObject:GetEntityID()
				wnpc.isSpawning = false
				wnpc.spawned = true
				wnpc.spawnedAtLeastOnce = true
				wnpc.spawnedTime = time

				-- workaround for other mods interfering with freshly spawned NPCs
				Cron.After(0.1, function()
					wnpc.onSpawn(spawnedObject)
				end)
				-- end
			end
		end
		table.remove(awaitingSpawnCallbacks, reqOnSpawnIndex)
	end)

	ObserveBefore("PreventionSpawnSystem", "DespawnCallback", function(this, entityID)
		local wnpc = Spawner.GetNPCByEntityID(entityID)
		if not wnpc then
			-- Not necessarily an error, npc not controlled by us i.e. police or removed from worldNPCs by Spawner.Remove
			log("OnPreventionUnitDespawnedRequest: NPC for entity ID not found")
			return
		end

		-- Edge case: sometimes DespawnCallback triggers immediately after a headshot for distant NPCs, and isDead is not set yet
		if Game.GetStatPoolsSystem():GetStatPoolValue(wnpc.entityID, gamedataStatPoolType.Health, false) == 0 then
			wnpc.isDead = true
		end

		if wnpc.isDead then
			Spawner.Remove(wnpc.id)
			return
		end

		wnpc.entityID = nil
		wnpc.spawned = false
		if wnpc.onDespawn then wnpc.onDespawn() end
	end)

	Observe("AIHumanComponent", "OnCommandStateChanged", function(command, oldState, newState)
		-- if newState ~= AICommandState.Executing then return nil end
		local currentCMD
		local currentCMDIndex
		for i, cmd in ipairs(activeCommands) do
			if cmd.command.state == AICommandState.Success then
				local callback = cmd.callback
				Cron.After(0.1, callback)
				table.remove(activeCommands, i)
				i = i - 1
			end
		end
	end)
end

function Spawner.PlayAnimationOnTarget(obj, animationName, instant, callback)
  local spawnTransform = obj:GetWorldTransform()
  spawnTransform:SetPosition(obj:GetWorldPosition())
  local angle = obj:GetWorldOrientation():ToEulerAngles().yaw
  spawnTransform:SetOrientationEuler(EulerAngles.new(0, 0, angle + 180))
  local entityID = exEntitySpawner.Spawn("base\\qr\\entity\\workspot_anim.ent", spawnTransform, '')
  Cron.RunWithRetry(0.1, 100, function()
  	local entity = Game.FindEntityByID(entityID)
  	if not entity then return false end
  	Game.GetWorkspotSystem():PlayInDeviceSimple(entity, obj, false, "qr_workspot_base", nil, nil, 0, 1, nil)
    Game.GetWorkspotSystem():SendJumpToAnimEnt(obj, animationName, instant)
    callback()
  end)
end

function Spawner.Update(dt)
	time = time + dt
	if time - lastUpdate < UPDATE_INTERVAL then return end
	lastUpdate = time

	local plPos = GetPlayer():GetWorldPosition()

	-- update last NPC positions
	for i, wnpc in pairs(worldNPCs) do
		if wnpc.spawned and IsDefined(wnpc.ref) then
			local objPos = wnpc.ref:GetWorldPosition()
			-- only update last npc pos when the player is close enough so the terrain won't stream out
			if Vector4.Distance(plPos, objPos) < 80 then
				wnpc.pos = wnpc.ref:GetWorldPosition()
			end
			local health = Game.GetStatPoolsSystem():GetStatPoolValue(wnpc.entityID, gamedataStatPoolType.Health, false)
			wnpc.isDead = health == 0
			wnpc.health = health
			wnpc.state = wnpc.ref:GetHighLevelStateFromBlackboard()
			wnpc.isDefeated = wnpc.ref:IsDefeated()
		end
	end

	-- cleanup dead to prevent respawning
	for i, wnpc in pairs(worldNPCs) do
		if not wnpc.spawned and not wnpc.isSpawning and wnpc.isDead then
			worldNPCs[i] = nil
		end
	end

	-- Detect if spawned NPC fell through the floor
	for i, wnpc in pairs(worldNPCs) do
		if wnpc.spawned and not wnpc.isSpawning and (time - wnpc.spawnedTime < 3) and Vector4.Distance2D(wnpc.spawnPos, wnpc.pos) < 2 and wnpc.spawnPos.z - wnpc.pos.z > 2.5 then
			wnpc.pos = Vector4.new(wnpc.spawnPos)
			Spawner.Despawn(wnpc.ref)
			wnpc.spawned = false
			wnpc.isSpawning = false
		end
	end

	-- spawn NPCs (new and despawned)
	for i, wnpc in pairs(worldNPCs) do
		-- if character is close to their original spawn pos, repsawn at original (to prevent drifting away in precise spawn locations)
		local isCloseToOriginalSpawn = Vector4.Distance2D(wnpc.spawnPos, wnpc.pos) < 2
		local spawnPos = isCloseToOriginalSpawn and wnpc.spawnPos or wnpc.pos
		if not wnpc.spawned and not wnpc.isSpawning and Spawner.CanSpawn(plPos, wnpc.pos, wnpc.spawnDistance, wnpc.getGround) then
			wnpc.isSpawning = true
			wnpc.spawnStartedAt = time

			-- if the character was lying on the ground, spawn higher
			if not isCloseToOriginalSpawn and (wnpc.isDefeated or wnpc.state == gamedataNPCHighLevelState.Unconscious) then
				spawnPos.z = spawnPos.z + 2
			end

			local spawnRadius = (not wnpc.spawnedAtLeastOnce) and wnpc.spawnRadius or 0
			local getGround = (not wnpc.spawnedAtLeastOnce) and wnpc.getGround or false
			Spawner.RequestNPCSpawn(wnpc.id, wnpc.tdbid, spawnPos, spawnRadius, getGround)
		end
	end
end

function Spawner.GetNPCByEntityID(entityID)
	for i, wnpc in pairs(worldNPCs) do
		if wnpc.entityID and wnpc.entityID.hash == entityID.hash then
			return wnpc
		end
	end
	return nil
end

function Spawner.GetGroundHeightAt(pos, rayDepth, rayStartOffset)
	if not pos then return nil end
	local rayStart = Vector4.new(pos)
	local rayEnd = Vector4.new(pos)
	rayStart.z = rayStart.z + rayStartOffset
	rayEnd.z = rayEnd.z - rayDepth
	rayStart.w = 0
	rayEnd.w = 0

	local terrainHit, tRes = Game.GetSpatialQueriesSystem():SyncRaycastByCollisionGroup(rayStart, rayEnd, CName'Terrain', true, false)
	local staticHit, sRes = Game.GetSpatialQueriesSystem():SyncRaycastByCollisionGroup(rayStart, rayEnd, CName'Static', true, false)
	if not terrainHit and not staticHit then
		return nil
	end
	if not tRes:IsValid() then return Vector4.Vector3To4(sRes.position) end
	if not sRes:IsValid() then return Vector4.Vector3To4(tRes.position) end
	local tHitPos = Vector4.Vector3To4(tRes.position)
	local sHitPos = Vector4.Vector3To4(sRes.position)
	local posW1 = Vector4.new(pos)
	tHitPos.w = 0
	sHitPos.w = 0
	posW1.w = 0
	return Vector4.Distance(posW1, tHitPos) < Vector4.Distance(posW1, sHitPos) and tHitPos or sHitPos
end

-- if the spawn point and everything in its radius are at the same height there's no need to find the ground
function Spawner.GetFlatSpawnPositionInRadius(pos, spawnRadius)
	local spreadX = (math.random() * 2 - 1) * spawnRadius
	local spreadY = (math.random() * 2 - 1) * spawnRadius
	return Vector4.new(pos.x - spreadX, pos.y - spreadY, pos.z, pos.w)
end

-- like above but also tries multiple times in the given radius until a ray finds the ground i.e. for sloping terrain
function Spawner.GetGroundedSpawnPositionInRadius(pos, spawnRadius, isWarmup)
	local newPos
	-- Issues with casting a ray beause the objects are not stream in yet
	-- TODO: Find a way to stream in the world at coords without looking
	for i = 1, 10, 1 do
		local spawnPosition = Spawner.GetFlatSpawnPositionInRadius(pos, spawnRadius)

		-- the terrain in the radius may be sloping so we're also checking with a higher starting point and larger depth
		newPos = Spawner.GetGroundHeightAt(spawnPosition, 2, 0.3)
		if newPos == nil then
			newPos = Spawner.GetGroundHeightAt(spawnPosition, 4, 2)
		end -- try again with higher height buffer
		if newPos == nil then
			newPos = Spawner.GetGroundHeightAt(spawnPosition, 3, 6)
		end -- try again with higher height buffer
		if newPos then break end
		if i == 10 and newPos == nil then
			if not isWarmup then errorLog("Spawn pos still nil!!!") end
			newPos = spawnPosition
		end
	end
	return newPos
end

function Spawner.CanSpawn(playerPos, objPos, spawnDist, requireTerrainHeight)
	spawnDist = spawnDist and spawnDist or 50
	local viewDir = Game.GetPlayer():GetWorldForward()
	local locDir = Vector4.Normalize(Vector4.new(objPos.x - playerPos.x, objPos.y - playerPos.y, objPos.z - playerPos.z, 0))
	local distanceDotFactor = math.max(0.5, Vector4.Dot(viewDir, locDir))
	local dist = Vector4.Distance2D(playerPos, objPos)
	local inRange = dist < (spawnDist * distanceDotFactor)

	-- local playerEyesPos, forward = Game.GetTargetingSystem():GetCrosshairData(Game.GetPlayer())
	-- local posVisible = Game.GetSenseManager():IsPositionVisible(objPos, playerEyesPos, false)
	-- why is IsPositionVisible so unreliable?
	if inRange then
		if requireTerrainHeight and not Spawner.GetGroundHeightAt(objPos, 2, 0.3) then
			trace("Can't spawn, ground not found", objPos)
			return false
		end
		-- if posVisible then
		--	 return true
		-- elseif dist < (spawnDist / 2 * distanceDotFactor) then
		--	 log("True despite not visible!")
			return true
		-- end
	end

	return false
end

function Spawner.reset()
	worldNPCs = {}
	awaitingSpawnCallbacks = {}
end

-- Send NPC far away, which triggers a despawn - workaround for broken RequestDespawn
function Spawner.Despawn(spawnedObject)
	if spawnedObject == nil or not IsDefined(spawnedObject) then return end

	Spawner.MoveNPC(spawnedObject, Vector4.new(9999, 9999, 0, 1), 1)

	-- This doesn't do anything but maybe keeps some counters clearer
	Game.GetPreventionSpawnSystem():RequestDespawn(spawnedObject:GetEntityID())
end

function Spawner.MoveNPC(obj, pos, rot, callback)
	local teleportCmd = AITeleportCommand.new()
	teleportCmd.position = pos
	teleportCmd.rotation = rot
	teleportCmd.doNavTest = false
	obj:GetAIControllerComponent():SendCommand(teleportCmd)

	if callback then
		table.insert(activeCommands, {
			command = teleportCmd,
			callback = callback,
		})
	end
end

function Spawner.Remove(npcID)
	local wnpc = worldNPCs[npcID]
	if not wnpc then
		errorLog("Spawner.Remove: NPC with given ID doesn't exist")
		return
	end
	worldNPCs[npcID] = nil
	if IsDefined(wnpc.ref) then Spawner.Despawn(wnpc.ref) end
	if wnpc.onDespawn then wnpc.onDespawn(true) end
end

local npcID = 1
function Spawner.SpawnNPC(npcTweakDBID, pos, spawnRadius, spawnDistance, getGround, onSpawn, onDespawn)
	local newNpcID = npcID
	npcID = npcID + 1
	worldNPCs[npcID] = {
		id = npcID,
		enitityID = nil,
		tdbid = npcTweakDBID,
		spawnPos = Vector4.new(pos),
		pos = pos,
		isDead = false,
		health = nil,
		spawned = false,
		isSpawning = false,
		spawnRadius = spawnRadius,
		spawnDistance = spawnDistance,
		getGround = getGround,
		onSpawn = onSpawn,
		onDespawn = onDespawn,
	}
	return worldNPCs[npcID]
end

function Spawner.RequestNPCSpawn(npcID, npcTweakDBID, pos, spawnRadius, getGround)
	local player = Game.GetPlayer()
	local heading = player:GetWorldForward()
	local spawnPositionGrounded = pos
	if getGround then
		spawnPositionGrounded = Spawner.GetGroundedSpawnPositionInRadius(pos, spawnRadius)
	else
		spawnPositionGrounded = Spawner.GetFlatSpawnPositionInRadius(pos, spawnRadius)
	end

	local spawnTransform = player:GetWorldTransform()
	spawnTransform:SetPosition(spawnPositionGrounded)
	spawnTransform:SetOrientationEuler(EulerAngles.new(0, 0, math.random(0, 360)))
	local reqId = Game.GetPreventionSpawnSystem():RequestUnitSpawn(npcTweakDBID, spawnTransform)
	table.insert(awaitingSpawnCallbacks, {
		npcID = npcID,
		reqId = reqId,
		requestTime = time,
	})
end

-- Workaround: owner param is for using an NPC as a proxy to spawn loose items
function Spawner.SpawnRandomLootItem(lootClassList, pos, spawnRadius, owner)
	local spawnPosition = Spawner.GetGroundedSpawnPositionInRadius(pos, spawnRadius)
	local item = ItemID.FromTDBID(TweakDBID.new(lootClassList[math.random(1, #lootClassList)]))

	Game.GetTransactionSystem():GiveItem(owner, item, 1)

	local instructions = { DropInstruction.Create(item, 1) }
	Game.GetLootManager():SpawnItemDropOfManyItems(owner, instructions, CName"playerDropBag", spawnPosition)
end

function Spawner.SpawnRandomMofosGroupInRadius(characterClassList, pos, spawnRadius, spawnDistance, getGround, onSpawn, onDespawn)
	local minEnemies = math.max(1, math.ceil(spawnRadius / 3))
	local maxEnemies = math.max(2, math.ceil(spawnRadius * 1.2))
	local enemyCount = math.floor(math.random(minEnemies, maxEnemies))
	for i = 1, enemyCount, 1 do
		Spawner.SpawnRandomMofoInRadius(characterClassList, pos, spawnRadius, spawnDistance, getGround, onSpawn, onDespawn)
	end
end

function Spawner.SpawnRandomMofoInRadius(characterClassList, pos, spawnRadius, spawnDistance, getGround, onSpawn, onDespawn)
	Spawner.SpawnNPC(TweakDBID.new(characterClassList[math.random(1, #characterClassList)]), pos, spawnRadius, spawnDistance, getGround, onSpawn, onDespawn)
end

return Spawner
