--[[--------------------------------------------------------------------------
	Boom - Modern Healing & Support Raid Frames
	Engine/Roster.lua

	Group membership, snapshotted.

	Every other module asks the same handful of questions about a unit over and
	over: what class is it, is it a healer, which raid group is it in, is it even
	in my group. Answering those live would mean a UnitClass /
	UnitGroupRolesAssigned / GetRaidRosterInfo round trip per question, per
	button, per update. Instead we rebuild one cached row per unit whenever the
	roster actually changes - which is rare - and hand that row out for free.

	Engines never draw. This file marks buttons dirty and sends
	BOOM_ROSTER_UPDATED; anything visible is the UI layer's problem.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local Roster = Boom:NewModule("Roster")

local Util = Boom.Util
local UPDATE = Boom.UPDATE

local wipe = wipe
local bor = bit.bor

local UnitExists, UnitGUID, UnitName, UnitIsUnit = UnitExists, UnitGUID, UnitName, UnitIsUnit
local UnitGroupRolesAssigned = UnitGroupRolesAssigned
local UnitIsGroupLeader, UnitIsGroupAssistant = UnitIsGroupLeader, UnitIsGroupAssistant
local GetNumGroupMembers, IsInRaid = GetNumGroupMembers, IsInRaid
local GetRaidRosterInfo = GetRaidRosterInfo

-- A roster change can rename a unit, reassign its role and flip it online or
-- offline all at once, so every rebuild dirties exactly these three.
local ROSTER_FLAGS = bor(UPDATE.NAME, UPDATE.ROLE, UPDATE.STATUS)

--------------------------------------------------------------------------------
-- Storage
--
-- All of these are allocated once and refilled in place. A raid-wide roster
-- event can fire several times a second during a wipe recovery; none of it may
-- produce garbage.
--------------------------------------------------------------------------------

local infoCache = {}   -- [unitToken]  = info row, reused across rebuilds
local unitSet   = {}   -- [unitToken]  = true, only for units in the group RIGHT NOW
local guidToUnit= {}   -- [guid]       = unitToken
local units     = {}   -- ordered array of the unit tokens above
local raidIndex = {}   -- [unitToken]  = 1..40 index, so one row can be rebuilt alone

local healerCount = 0

-- Names the client has not sent yet.
--
-- UnitName does not fail when a player's name is unknown - it answers with the
-- global UNKNOWNOBJECT placeholder ("Unknown"), which is a perfectly good string
-- and would be cached and drawn as if it were real. Both that and an outright
-- nil mean the same thing: ask again later.
local UNKNOWN = _G.UNKNOWNOBJECT or "Unknown"

local pendingNames = 0

-- Pre-built token strings. Concatenating "raid" .. i forty times per rebuild is
-- pure garbage; these never change.
local raidUnits, partyUnits = {}, {}
for i = 1, 40 do raidUnits[i] = "raid" .. i end
for i = 1, 4  do partyUnits[i] = "party" .. i end

Roster.groupType = "solo"

--------------------------------------------------------------------------------
-- Row construction
--------------------------------------------------------------------------------

--- Refresh the cached row for `unit`.
--- `raidIndex` is the 1..40 index into GetRaidRosterInfo, or nil outside a raid.
---
--- The row table is reused, not wiped: every field below is assigned
--- unconditionally (nil included), so nothing can go stale, and we skip the
--- rehash that a wipe + refill would cost.
--- The client's answer for a unit's name, or nil while it does not have one.
--- Collapses UNKNOWNOBJECT to nil so callers have exactly one "not yet" case.
local function readName(unit, fromRaidRoster)
	local name = fromRaidRoster
	if not name or name == UNKNOWN then name = UnitName(unit) end
	if not name or name == UNKNOWN or name == "" then return nil end
	return name
end

--- readName for a unit already in the roster, consulting the raid roster row
--- when there is one. Used by the two late-name paths below.
local function currentName(unit)
	local index = raidIndex[unit]
	local fromRaid
	if index and GetRaidRosterInfo then
		fromRaid = (GetRaidRosterInfo(index))
	end
	return readName(unit, fromRaid)
end

local function buildInfo(unit, index)
	local info = infoCache[unit]
	if not info then
		info = {}
		infoCache[unit] = info
	end

	local name, classToken, group, combatRole
	local isLeader, isAssistant, isMainTank, isMainAssist = false, false, false, false

	if index and GetRaidRosterInfo then
		-- name, rank, subgroup, level, class, fileName, zone, online, isDead,
		-- role, isML, combatRole
		local rName, rank, subgroup, _, _, fileName, _, _, _, raidRole, _, rCombatRole =
			GetRaidRosterInfo(index)

		name         = rName
		classToken   = fileName
		group        = subgroup
		combatRole   = rCombatRole
		isLeader     = rank == 2
		isAssistant  = rank == 1
		isMainTank   = raidRole == "MAINTANK"
		isMainAssist = raidRole == "MAINASSIST"
	else
		-- Party members carry no subgroup; treat the party as group 1 so callers
		-- can sort by group without special-casing.
		group = 1
		if UnitIsGroupLeader then
			isLeader = UnitIsGroupLeader(unit) and true or false
		end
		if UnitIsGroupAssistant then
			isAssistant = UnitIsGroupAssistant(unit) and true or false
		end
	end

	-- GetRaidRosterInfo returns nils for a slot the client has not filled in yet
	-- (zoning, a member logging in). Unit APIs usually know sooner.
	name = readName(unit, name)
	if not classToken then classToken = Util.UnitClassToken(unit) end

	-- Counted, not merely recorded: a still-nameless unit is why Rebuild arms a
	-- retry. Without one, a member whose name arrives with no accompanying roster
	-- event keeps a blank frame until the next unrelated group change.
	if not name then pendingNames = pendingNames + 1 end

	local role = UnitGroupRolesAssigned(unit)
	if not role or role == "NONE" then
		-- Fall back to the raid's stored combat role: a player who has not set a
		-- role in a premade raid still has one recorded from their spec.
		role = (combatRole and combatRole ~= "NONE") and combatRole or "NONE"
	end

	info.guid         = UnitGUID(unit)
	info.name         = name
	info.shortName    = name and Util.StripRealm(name) or nil
	info.classToken   = classToken
	info.role         = role
	info.group        = group
	info.isPlayer     = UnitIsUnit(unit, "player") and true or false
	info.isLeader     = isLeader
	info.isAssistant  = isAssistant
	info.isMainTank   = isMainTank
	info.isMainAssist = isMainAssist

	return info
end

local function addUnit(unit, index)
	local info = buildInfo(unit, index)

	units[#units + 1] = unit
	unitSet[unit] = true
	raidIndex[unit] = index
	if info.guid then guidToUnit[info.guid] = unit end
	if info.role == "HEALER" then healerCount = healerCount + 1 end
end

--------------------------------------------------------------------------------
-- Rebuild
--------------------------------------------------------------------------------

--- Take a fresh snapshot of the group. Cheap enough to call directly, but every
--- event path goes through the throttle instead.
function Roster:Rebuild()
	local numMembers = GetNumGroupMembers() or 0
	local inRaid = IsInRaid()

	wipe(units)
	wipe(unitSet)
	wipe(guidToUnit)
	wipe(raidIndex)
	healerCount = 0
	pendingNames = 0

	if inRaid and numMembers > 0 then
		self.groupType = "raid"
		-- In a raid the player is one of the raidN tokens; "player" is aliased
		-- back onto it by GetUnitInfo via the GUID map.
		for i = 1, numMembers do
			local unit = raidUnits[i]
			if unit and UnitExists(unit) then
				addUnit(unit, i)
			end
		end

	elseif numMembers > 0 then
		self.groupType = "party"
		-- GetNumGroupMembers counts the player, but there is no "party0".
		addUnit("player")
		for i = 1, numMembers - 1 do
			local unit = partyUnits[i]
			if unit and UnitExists(unit) then
				addUnit(unit)
			end
		end

	else
		self.groupType = "solo"
		addUnit("player")
	end

	Boom:Debug("roster rebuilt:", self.groupType, #units, "units,", healerCount, "healers",
		pendingNames, "unnamed")

	Boom:SendMessage("BOOM_ROSTER_UPDATED")
	Boom:MarkAllDirty(ROSTER_FLAGS)

	self:ScheduleNameRetry()
end

-- One closure, created once, so the throttle never allocates.
local rebuild = function() Roster:Rebuild() end

local function scheduleRebuild()
	-- Joining a raid fires GROUP_ROSTER_UPDATE once per member; coalesce.
	Boom:Throttle("roster", 0.1, rebuild)
end

--------------------------------------------------------------------------------
-- Late names
--
-- Joining a group is not one event, it is a conversation: GROUP_ROSTER_UPDATE
-- arrives first and the names follow, individually, as the server answers for
-- each player. A member who is out of range, in another zone, or still zoning in
-- has no name at the moment the roster is built.
--
-- UNIT_NAME_UPDATE is the client telling us one of those answers landed, and it
-- is the whole reason the frames used to sit there blank: without it nothing
-- ever asked again, so a name that was not ready during the 0.1s after joining
-- stayed missing until some unrelated roster change happened to rebuild.
--
-- The timer is the belt to that event's braces. UNIT_NAME_UPDATE is not fired
-- for every case (a unit that only becomes queryable when it comes into range
-- is the common miss), so a bounded sweep re-reads whatever is still nameless.
--------------------------------------------------------------------------------

local NAME_RETRY_DELAY   = 0.5
local NAME_RETRY_ATTEMPTS = 12   -- ~6 seconds, then stop asking

local retryGeneration = 0

--- Re-read the names we are still missing. Returns true while any remain.
local function sweepNames()
	local stillMissing = 0

	for i = 1, #units do
		local unit = units[i]
		local info = infoCache[unit]
		if info and not info.name then
			local name = currentName(unit)

			if name then
				info.name = name
				info.shortName = Util.StripRealm(name)
				-- Class colour is read from the unit, and a unit the client could
				-- not name usually could not be classed either.
				if not info.classToken then info.classToken = Util.UnitClassToken(unit) end
				Boom:MarkUnitDirty(unit, ROSTER_FLAGS)
			else
				stillMissing = stillMissing + 1
			end
		end
	end

	pendingNames = stillMissing
	return stillMissing > 0
end

function Roster:ScheduleNameRetry()
	retryGeneration = retryGeneration + 1
	if pendingNames == 0 then return end

	local generation = retryGeneration
	local attempts = 0

	local function tick()
		-- A rebuild since this chain started owns the problem now.
		if generation ~= retryGeneration then return end

		attempts = attempts + 1
		if sweepNames() and attempts < NAME_RETRY_ATTEMPTS then
			Boom:After(NAME_RETRY_DELAY, tick)
		end
	end

	Boom:After(NAME_RETRY_DELAY, tick)
end

--- One unit's name just became available.
local function onNameUpdate(_, _, unit)
	if not unit then return end

	local info = unitSet[unit] and infoCache[unit]
	if not info then
		-- Not a roster token. It may still be a group member reached through
		-- "target"/"mouseover", in which case the GUID map finds the real row.
		local mapped = Roster:GetUnitByGUID(UnitGUID(unit))
		if not mapped then return end
		unit, info = mapped, infoCache[mapped]
		if not info then return end
	end

	local name = currentName(unit)
	if not name or name == info.name then return end

	info.name = name
	info.shortName = Util.StripRealm(name)
	if not info.classToken then info.classToken = Util.UnitClassToken(unit) end

	if pendingNames > 0 then pendingNames = pendingNames - 1 end
	Boom:MarkUnitDirty(unit, ROSTER_FLAGS)

	-- Names trickle in one at a time. Each arrival re-arms the safety net for the
	-- ones still outstanding, so a group where they land over half a minute is
	-- covered rather than only the first few seconds after joining.
	if pendingNames > 0 then Roster:ScheduleNameRetry() end
end

--------------------------------------------------------------------------------
-- Queries
--------------------------------------------------------------------------------

--- Cached row for a unit:
--- { guid, name, shortName, classToken, role, group,
---   isPlayer, isLeader, isAssistant, isMainTank, isMainAssist }
--- The table is owned by Roster and refreshed in place - read it, never keep it
--- across a BOOM_ROSTER_UPDATED.
function Roster:GetUnitInfo(unit)
	if not unit then return nil end

	local info = unitSet[unit] and infoCache[unit]
	if info then return info end

	-- Tokens that are not roster tokens still resolve: "player" while in a raid,
	-- or "target" / "mouseover" pointing at a group member.
	local guid = UnitGUID(unit)
	local mapped = guid and guidToUnit[guid]
	if mapped then return infoCache[mapped] end

	return nil
end

function Roster:GetUnitByGUID(guid)
	return guid and guidToUnit[guid] or nil
end

--- The unit's name, or nil while the client still has not sent one.
---
--- Preferred over a bare UnitName at every call site that draws: this answers
--- from the roster row, which is corrected as late names arrive, and it never
--- hands back the UNKNOWNOBJECT placeholder as though it were a real name.
--- `short` strips the realm suffix.
function Roster:GetName(unit, short)
	if not unit then return nil end

	local info = self:GetUnitInfo(unit)
	if info and info.name then
		return short and info.shortName or info.name
	end

	-- Not a group member (a pet, a preview button pointed at "player" while
	-- solo, a mouseover). Nothing is cached for those, so ask directly.
	local name = readName(unit)
	if not name then return nil end
	return short and Util.StripRealm(name) or name
end

--- How many group members the client still has no name for. Diagnostics only.
function Roster:GetPendingNameCount()
	return pendingNames
end

function Roster:GetGroupType()
	return self.groupType
end

function Roster:IsInGroup(unit)
	return self:GetUnitInfo(unit) ~= nil
end

function Roster:GetHealerCount()
	return healerCount
end

function Roster:GetUnitCount()
	return #units
end

--- for unit in Boom.Roster:IterateUnits() do ... end
function Roster:IterateUnits()
	local i = 0
	return function()
		i = i + 1
		return units[i]
	end
end

--- Raid subgroup 1..8. Party members report 1; unknown units report nil.
function Roster:GetRaidGroup(unit)
	local info = self:GetUnitInfo(unit)
	return info and info.group or nil
end

--------------------------------------------------------------------------------
-- Lifecycle
--------------------------------------------------------------------------------

function Roster:OnEnable()
	self:RegisterEvent("GROUP_ROSTER_UPDATE", scheduleRebuild)
	self:RegisterEvent("PLAYER_ROLES_ASSIGNED", scheduleRebuild)
	self:RegisterEvent("PARTY_LEADER_CHANGED", scheduleRebuild)
	self:RegisterEvent("PLAYER_ENTERING_WORLD", scheduleRebuild)

	-- The event that makes a late name appear. Cheap: it carries the unit token,
	-- so nothing but that one row is touched.
	self:RegisterEvent("UNIT_NAME_UPDATE", onNameUpdate)

	-- Have data ready before the headers ask for it; PLAYER_ENTERING_WORLD will
	-- correct anything the client had not populated yet at login.
	self:Rebuild()
end
