assert(oRA, "oRA not found!")


------------------------------
--      Are you local?      --
------------------------------

local MAX_BUFFS = 16
local PLAYER = UnitName("player")


local BS = AceLibrary("Babble-Spell-2.0")
local Compost = AceLibrary("Compost-2.0")
local Roster = AceLibrary("RosterLib-2.0")
local SEA = AceLibrary("SpecialEvents-Aura-2.0")

local L = AceLibrary("AceLocale-2.0"):new("oRAOBuff")


local Buffs = {
	[BS["Power Word: Fortitude"]] = {
		["duration"] = 1800,
		["ra_id"] = 1,
		["ra_subid"] = 1,
	},
	[BS["Prayer of Fortitude"]] = {
		["duration"] = 3600,
		["ra_id"] = 1,
		["ra_subid"] = 2,
	},
	[BS["Mark of the Wild"]] = {
		["duration"] = 1800,
		["ra_id"] = 2,
		["ra_subid"] = 1,
	},
	[BS["Gift of the Wild"]] = {
		["duration"] = 3600,
		["ra_id"] = 2,
		["ra_subid"] = 2,
	},
	[BS["Arcane Intellect"]] = {
		["duration"] = 1800,
		["ra_id"] = 3,
		["ra_subid"] = 1,
	},
	[BS["Arcane Brilliance"]] = {
		["duration"] = 3600,
		["ra_id"] = 3,
		["ra_subid"] = 2,
	},
	-- 4 seems to be the "Admiral's Hat" buff
	[BS["Shadow Protection"]] = {
		["duration"] = 600,
		["ra_id"] = 5,
		["ra_subid"] = 1,
	},
	[BS["Prayer of Shadow Protection"]] = {
		["duration"] = 1200,
		["ra_id"] = 5,
		["ra_subid"] = 2,
	},
	[BS["Power Word: Shield"]] = {
		["duration"] = 30,
		["ra_id"] = 6,
	},
	[BS["Soulstone Resurrection"]] = {
		["duration"] = 1800,
		["ra_id"] = 7,
	},
	[BS["Divine Spirit"]] = {
		["duration"] = 1800,
		["ra_id"] = 8,
		["ra_subid"] = 1,
	},
	[BS["Prayer of Spirit"]] = {
		["duration"] = 3600,
		["ra_id"] = 8,
		["ra_subid"] = 2,
	},
	[BS["Thorns"]] = {
		["duration"] = 600,
		["ra_id"] = 9,
	},
	[BS["Fear Ward"]] = {
		["duration"] = 600,
		["ra_id"] = 10,
	},
	[BS["Blessing of Might"]] = {
		["duration"] = 300,
		["ra_id"] = 11,
		["ra_subid"] = 1,
	},
	[BS["Greater Blessing of Might"]] = {
		["duration"] = 900,
		["ra_id"] = 11,
		["ra_subid"] = 2,
	},
	[BS["Blessing of Wisdom"]] = {
		["duration"] = 300,
		["ra_id"] = 12,
		["ra_subid"] = 1,
	},
	[BS["Greater Blessing of Wisdom"]] = {
		["duration"] = 900,
		["ra_id"] = 12,
		["ra_subid"] = 2,
	},
	[BS["Blessing of Kings"]] = {
		["duration"] = 300,
		["ra_id"] = 13,
		["ra_subid"] = 1,
	},
	[BS["Greater Blessing of Kings"]] = {
		["duration"] = 900,
		["ra_id"] = 13,
		["ra_subid"] = 2,
	},
	[BS["Blessing of Salvation"]] = {
		["duration"] = 300,
		["ra_id"] = 14,
		["ra_subid"] = 1,
	},
	[BS["Greater Blessing of Salvation"]] = {
		["duration"] = 900,
		["ra_id"] = 14,
		["ra_subid"] = 2,
	},
	[BS["Blessing of Light"]] = {
		["duration"] = 300,
		["ra_id"] = 15,
		["ra_subid"] = 1,
	},
	[BS["Greater Blessing of Light"]] = {
		["duration"] = 900,
		["ra_id"] = 15,
		["ra_subid"] = 2,
	},
	[BS["Blessing of Sanctuary"]] = {
		["duration"] = 300,
		["ra_id"] = 16,
		["ra_subid"] = 1,
	},
	[BS["Greater Blessing of Sanctuary"]] = {
		["duration"] = 900,
		["ra_id"] = 16,
		["ra_subid"] = 2,
	},
	[BS["Renew"]] = {
		["duration"] = 15,
		["ra_id"] = 17,
	},
	[BS["Rejuvenation"]] = {
		["duration"] = 12,
		["ra_id"] = 18,
	},
	[BS["Regrowth"]] = {
		["duration"] = 21,
		["ra_id"] = 19,
	},
	[BS["Amplify Magic"]] = {
		["duration"] = 600,
		["ra_id"] = 20,
	},
	[BS["Dampen Magic"]] = {
		["duration"] = 600,
		["ra_id"] = 21,
	},
}
local BuffNames, _Buffs_Lookup, _Buffs_AmbigiousLookup = {}, {}, {}

local UnitBuffsTables, _UnitsDirty, _Units = {}, false, {}


--------------------------------
--      Unit Buff Tables      --
--------------------------------

-- fallback for unit Buffs table lookup
local function _UnitBuffsTable_Index(unitbuffs, buffname)
	-- check if the buff can be found using an alternate (unique) key
	local buff = _Buffs_Lookup[buffname]
	if buff then return unitbuffs[buff["name"]] end

	-- check if the buff can be found using ambigious keys
	local possiblebuffs = _Buffs_AmbigiousLookup[buffname]
	if possiblebuffs then
		local endtime
		for _, buff in pairs(possiblebuffs) do
			endtime = unitbuffs[buff["name"]]
			if endtime then return endtime end
		end
	end
end


local function UnitBuffsTable_New()
	local t, mt = Compost:Acquire(), Compost:Acquire()
	mt["__index"] = _UnitBuffsTable_Index
	setmetatable(t, mt)
	return t
end

local function UnitBuffsTable_Free(t)
	Compost:Reclaim(getmetatable(t))
	Compost:Reclaim(t)
end


-----------------------------------
--      Iterators for Buffs      --
-----------------------------------

local function _DoNothing() end

local function Iterate(buffname)
	local buff = Buffs[buffname]
	if buff then
		local duration = buff["duration"]
		if duration then
			local buffname, now, texture = buff["name"], GetTime(), buff["texture"]

			local iterator = function(_unitbuffstables, unitname)
				local unitbuffs, endtime
				repeat
					unitname, unitbuffs = next(_unitbuffstables, unitname)
					if not unitbuffs then return end
					endtime = unitbuffs[buffname] or 0
				until endtime > now
				return unitname, texture, duration, endtime - now
			end

			return iterator, UnitBuffsTables
		end
	end

	return _DoNothing
end

local function IterateUnit(unitname)
	local unitbuffs = UnitBuffsTables[unitname]
	if unitbuffs then
		local now = GetTime()
		
		local iterator = function(_unitbuffs, buffname)
			local buff, endtime
			repeat
				buffname, endtime = next(_unitbuffs, buffname)
				if not buffname then return end
			until endtime > now
			local buff, timeleft = Buffs[buffname], endtime - now
			return buffname, buff["texture"], buff["duration"] or timeleft, timeleft
		end

		return iterator, unitbuffs
	end

	return _DoNothing
end


----------------------------
--      Localization      --
----------------------------

L:RegisterTranslations("enUS", function() return {
	["buffoptional"] = true,
	["Optional/Buff"] = true,
	["buff"] = true,
	["Buff"] = true,
	["Options for buffs."] = true,
	["print"] = true,
	["Print buff times"] = true,
	["Print the remaining buff times."] = true,

	["Buffs"] = true,
	["unitbuff"] = true,
	["Buffs on %s"] = true,
	["Buffs (per unit)"] = true,
} end)


----------------------------------
--      Module Declaration      --
----------------------------------

oRAOBuff = oRA:NewModule(L["buffoptional"])

oRAOBuff.defaults = {
}
oRAOBuff.optional = true
oRAOBuff.name = L["Optional/Buff"]
oRAOBuff.consoleCmd = L["buff"]
oRAOBuff.consoleOptions = {
	type = "group",
	name = L["Buff"],
	desc = L["Options for buffs."],
	args = {
		[L["print"]] = {
			type = "execute",
			name = L["Print buff times"],
			desc = L["Print the remaining buff times."],
			func = function()
				oRAOBuff:PrintTimesLeft()
			end,
		},
		["test"] = {
			type = "text",
			name = "Test Sync Message",
			desc = "Test with a given sync message.",
			get = false,
			set = function(v)
				oRAOBuff:UnitBuffReceived(v, PLAYER)
			end,
			usage = "<sync message>",
			visible = function() oRAOBuff:IsDebugging() end,
		},
	}
}


------------------------------
--      Initialization      --
------------------------------

function oRAOBuff:OnInitialize()
	local core, name = self.core, L["buffoptional"]

	-- register at core
	core:RegisterModule(name, self)
	core:RegisterDefaults(name, "profile", self.defaults)
	self.db = core:AcquireDBNamespace(name)
	self.debugFrame = core.debugFrame

	-- CTRA sends buff "RN <time> <id> <subid>", so create a 2nd table for indexing
	-- with "<id> <subid>" as well as a table for lookup up by texture. They are not
	-- unique, more buffs can have the same texture!
	-- Also set the name and spell icon entries...
	local existingbuffs, existingbuff, ra_id, texture
	for buffname, buff in pairs(Buffs) do
		buff["name"] = buffname
		table.insert(BuffNames, buffname)

		texture = BS:GetSpellIcon(buffname)
		buff["texture"] = texture

		ra_id = buff["ra_id"]
		if ra_id then
			_Buffs_Lookup[ra_id.." "..(buff["ra_subid"] or "0")] = buff

			if not buff["group"] then buff["group"] = ra_id end
		end

		existingbuffs = _Buffs_AmbigiousLookup[texture]
		if existingbuffs then
			table.insert(existingbuffs, buff)
		else
			existingbuff = _Buffs_Lookup[texture]
			if existingbuff then
				_Buffs_AmbigiousLookup[texture] = { existingbuff, buff }
				_Buffs_Lookup[texture] = nil
			else
				-- so far this texture is unique
				_Buffs_Lookup[texture] = buff
			end
		end
	end

	-- Instead of copying the entries, set the metatable - that way buffs will only
	-- be iterated once when using pairs(Buffs)
	setmetatable(Buffs, {__index = _Buffs_Lookup})
	table.sort(BuffNames)

	self:SetDebugging(true)
end

function oRAOBuff:OnEnable()
	-- acquire or reclaim tables for units joining or leaving the raid
    self:RegisterEvent("RosterLib_RosterChanged", "RosterChanged")
	self:RegisterEvent("SpecialEvents_AuraRaidRosterUpdate", "RosterChanged_AurasUpdated")
	self:RegisterEvent("SpecialEvents_AuraPartyMembersChanged", "RosterChanged_AurasUpdated")

	-- CTRA check
    self:RegisterCheck("RN", "UnitBuffReceived")

	-- keep track of buffs on the player
	self:RegisterEvent("SpecialEvents_PlayerBuffGained", "PlayerBuffGained")
	self:RegisterEvent("PLAYER_AURAS_CHANGED", "PlayerBuffsChanged")

	-- keep track of buffs on units in range
	self:RegisterEvent("SpecialEvents_UnitBuffGained", "UnitBuffGained")
    self:RegisterEvent("SpecialEvents_UnitBuffLost", "UnitBuffLost")

	-- create player entry
	self:UnitJoined(PLAYER)

	-- register our timber bars
	if oRAOTimer then
		oRAOTimer:Register(
			L["buff"],
			"oRAOBuff_BuffUpdated",
			Iterate,
			L["Buffs"],
			nil,
			BuffNames
		)
		oRAOTimer:Register(
			L["unitbuff"],
			"oRAOBuff_UnitUpdated",
			IterateUnit,
			L["Buffs (per unit)"],
			L["Buffs on %s"]
		)
	end
end


------------------------------
--      Event Handlers      --
------------------------------

-- handle units joining and leaving the party or raid
-- this could be called before or after or after RosterChanged_AurasUpdated
function oRAOBuff:RosterChanged(units)
	local _Object, _UnitJoined
	if _UnitsDirty then
		_Object = self
		_UnitJoined = self.UnitJoined
	else
		_Object = _Units
		_UnitJoined = table.insert
	end

	local name, oldname
	for _, unit in pairs(units) do
		name, oldname = unit["name"], unit["oldname"]
		if not oldname then
			_UnitJoined(_Object, name)
		elseif not name then
			self:UnitLeft(oldname)
		end
	end

	_UnitsDirty  = not _UnitsDirty
end

function oRAOBuff:RosterChanged_AurasUpdated()
	if _UnitsDirty then
		-- SE-Aura completed scanning the new units - it's safe to add them now
		for _, unitname in ipairs(_Units) do
			self:UnitJoined(unitname)
		end
		Compost:Erase(_Units)
	end

	_UnitsDirty  = not _UnitsDirty
end


-- handle received buff durations
function oRAOBuff:UnitBuffReceived(message, sender)
	-- we only receive messages from party/raid - the unit has to be in the roster
	local unitbuffs = UnitBuffsTables[sender]

	local _, _, timeleft, _buff = string.find(message, "RN ([%d]+) ([%d ]+)")
	timeleft = tonumber(timeleft)
	if not timeleft then
		self:Debug("SYNC from "..sender..": '"..message.."' has invalid format")
		return
	end
	local buff = Buffs[_buff]
	if not buff then
		self:Debug("SYNC from "..sender..": buff '".._buff.."' not found")
		return
	end

	self:BuffChanged(sender, buff["name"], (timeleft > 1) and timeleft, "SYNC")
end


-- player gaining a buff - also is triggered at login
function oRAOBuff:PlayerBuffGained(buffname)
	local unitbuffs, buff = UnitBuffsTables[PLAYER], Buffs[buffname]
	if not buff then return end

	local timeleft = GetPlayerBuffTimeLeft(GetPlayerBuff(SEA:UnitHasBuff("player", buffname), "HELPFUL"))
	self:BuffChanged(PLAYER, buff["name"], timeleft, "PLAYER")
end

-- at least one buff has changed... maybe rebuffed?
function oRAOBuff:PlayerBuffsChanged()
	local unitbuffs = UnitBuffsTables[PLAYER]
	local buff, buffindex

	for _buffname, buffid in SEA:BuffIter("player") do
		buff = Buffs[_buffname]
		if buff then
			buffindex = GetPlayerBuff(buffid, "HELPFUL")
			if buffindex >= 0 then
				self:BuffChanged(PLAYER, buff["name"], GetPlayerBuffTimeLeft(buffindex), "PLAYER")
			end
		end
	end
end


-- handle units receiving Buffs and add them to the table if the duration is known
function oRAOBuff:UnitBuffGained(unitid, buffname)
	-- check if this update was triggered because an aura changed,
	-- not because a unit joined the raid or was moved
	if arg1 ~= unitid then return end

	-- we're not interested in "player"...
	if UnitIsUnit(unitid, "player") then return end

	-- check if we're interested in the buff
	local buff = Buffs[buffname]
	if not buff then return end

	local timeleft = buff["duration"]
	if timeleft then
		self:BuffChanged(UnitName(unitid), buff["name"], timeleft, "UNIT")
	end
end

-- units losing a buff
function oRAOBuff:UnitBuffLost(unitid, buffname)
	-- we're not interested in "target"...
	if unitid == "target" then return end

	self:BuffChanged(UnitName(unitid), buffname, nil, "UNIT")
end


---------------------------------
--      Utility Functions      --
---------------------------------

function oRAOBuff:UnitJoined(unitname)
	self:Debug(unitname.." joined")

	local unit = Roster:GetUnitObjectFromName(unitname)
	if not UnitBuffsTables[unitname] then
		UnitBuffsTables[unitname] = UnitBuffsTable_New()
	end
	if unit then
		unit["ora_buffs"] = UnitBuffsTables[unitname]
	end
end

function oRAOBuff:UnitLeft(unitname)
	self:Debug(unitname.." left")

	local unitbuffs = UnitBuffsTables[unitname]
	if not unitbuffs then return end	-- should never happen, but i got it when a raid changed into a BG raid... /shrug
	UnitBuffsTables[unitname] = nil

	-- trigger events to update the unit display (if active)
	for buffname, _ in pairs(unitbuffs) do
		self:TriggerEvent("oRAOBuff_BuffUpdated", buffname)
	end
	self:TriggerEvent("oRAOBuff_UnitUpdated", unitname)

	-- finally release the unit buffs table
	UnitBuffsTable_Free(unitbuffs)
end


function oRAOBuff:BuffChanged(unitname, buffname, timeleft, source)
	local unitbuffs = UnitBuffsTables[unitname]
	if not unitbuffs then return end

	local now = GetTime()
	if timeleft then
		local endtime = now + timeleft

		if math.abs(endtime - (unitbuffs[buffname] or 0)) > 1 then
			self:Debug(source..": "..unitname.."'s "..buffname.." ends in "..timeleft)
			unitbuffs[buffname] = endtime

			self:TriggerEvent("oRAOBuff_BuffUpdated", buffname)
			self:TriggerEvent("oRAOBuff_UnitUpdated", unitname)
		end
	else
		if ((unitbuffs[buffname] or 0) - now) > 1 then
			self:Debug(source..": "..unitname.." lost "..buffname)
			unitbuffs[buffname] = nil

			self:TriggerEvent("oRAOBuff_BuffUpdated", buffname)
			self:TriggerEvent("oRAOBuff_UnitUpdated", unitname)
		end
	end
end


function oRAOBuff:PrintTimesLeft()
	local now, endtime, time

	now = GetTime()
	for unitname, unitbuffs in UnitBuffsTables do
		self:Print("Buffs for "..unitname..":")

		for buffname, endtime in pairs(unitbuffs) do
			-- is the end time not in the past?
			time = endtime - now
			if time > 0 then self:Print("   "..buffname..": "..SecondsToTimeAbbrev(time)) end
		end
	end
end
