--[[--------------------------------------------------------------------------
	Boom - Modern Healing & Support Raid Frames
	Core/Init.lua

	Foundation layer. Loaded first. Provides:
	  * the shared addon namespace
	  * a tiny module system (no Ace3 dependency - Boom ships zero libraries)
	  * an event bus and an internal message bus
	  * a dirty-flag update scheduler so unit buttons refresh once per frame
	  * a combat-safe execution queue (secure attributes may only change out of combat)

	Nothing in this file may depend on any other Boom file.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

-- Expose the namespace so users can poke at it from macros and /run.
_G.Boom = Boom

Boom.ADDON_NAME = ADDON_NAME
Boom.VERSION    = C_AddOns.GetAddOnMetadata(ADDON_NAME, "Version") or "1.0.0"

-- Interface build we were authored against (Midnight 12.0.7).
Boom.BUILD = select(4, GetBuildInfo())

--------------------------------------------------------------------------------
-- Locals
--------------------------------------------------------------------------------

local type, pairs, ipairs, select = type, pairs, ipairs, select
local tinsert, tremove, wipe = table.insert, table.remove, wipe
local setmetatable = setmetatable
local InCombatLockdown = InCombatLockdown
local band, bor = bit.band, bit.bor

--------------------------------------------------------------------------------
-- Update flags
--
-- Every unit button carries a bitmask of pending work. Engines set bits, the
-- driver frame flushes them once per screen frame. This keeps us off OnUpdate
-- polling entirely and means twenty simultaneous UNIT_AURA events cost one
-- aura rebuild, not twenty.
--------------------------------------------------------------------------------

local UPDATE = {
	HEALTH    = 0x0001, -- health, heal prediction, absorbs, heal absorbs
	POWER     = 0x0002, -- power bar value / colour
	AURA      = 0x0004, -- HoTs, debuffs, dispels, support auras
	RANGE     = 0x0008, -- in/out of range alpha
	STATUS    = 0x0010, -- dead / offline / ghost / phased / rez / summon
	NAME      = 0x0020, -- name text, class colour
	ROLE      = 0x0040, -- role, leader, assistant icons
	THREAT    = 0x0080, -- aggro highlight
	TARGET    = 0x0100, -- target / focus highlight
	RAIDICON  = 0x0200, -- raid target marker
	READY     = 0x0400, -- ready check state
	VEHICLE   = 0x0800, -- vehicle / charmed unit swap
	LAYOUT    = 0x1000, -- geometry changed, rebuild indicator positions
	COLOR     = 0x2000, -- colour settings changed
}
UPDATE.ALL = 0x3FFF
Boom.UPDATE = UPDATE

--------------------------------------------------------------------------------
-- Module system
--------------------------------------------------------------------------------

local modules, moduleOrder = {}, {}
Boom.modules = modules

local ModuleBase = {}
ModuleBase.__index = ModuleBase
Boom.ModuleBase = ModuleBase

--- Create (or fetch) a module. Modules are initialised in creation order, which
--- is file load order, which is the order declared in Boom.toc.
--- @param name string unique module name, also exposed as Boom[name]
function Boom:NewModule(name)
	if modules[name] then return modules[name] end

	local module = setmetatable({
		moduleName = name,
		enabled    = false,
	}, ModuleBase)

	modules[name] = module
	moduleOrder[#moduleOrder + 1] = module
	Boom[name] = module

	return module
end

function Boom:GetModule(name)
	return modules[name]
end

--- Iterate modules in load order.
function Boom:IterateModules()
	local i = 0
	return function()
		i = i + 1
		return moduleOrder[i]
	end
end

--------------------------------------------------------------------------------
-- Event bus
--
-- One frame, one OnEvent, callbacks stored per event. Handlers receive
-- (event, ...) exactly as the game delivers them.
--------------------------------------------------------------------------------

local eventFrame = CreateFrame("Frame", "BoomEventFrame")
local eventHandlers = {}  -- [event] = { {owner = obj, func = fn}, ... }
local messageHandlers = {}

local function addHandler(store, key, owner, func, registerFn)
	local list = store[key]
	if not list then
		list = {}
		store[key] = list
		if registerFn then registerFn(key) end
	end

	-- Replace an existing registration by the same owner rather than duplicating.
	for i = 1, #list do
		if list[i].owner == owner then
			list[i].func = func
			return
		end
	end

	list[#list + 1] = { owner = owner, func = func }
end

local function removeHandler(store, key, owner, unregisterFn)
	local list = store[key]
	if not list then return end

	for i = #list, 1, -1 do
		if list[i].owner == owner then
			tremove(list, i)
		end
	end

	if #list == 0 then
		store[key] = nil
		if unregisterFn then unregisterFn(key) end
	end
end

local function registerGameEvent(event)
	-- Some "events" are internal-only pseudo events; guard against those.
	local ok = pcall(eventFrame.RegisterEvent, eventFrame, event)
	if not ok then
		Boom:Debug("could not register event", event)
	end
end

local function unregisterGameEvent(event)
	pcall(eventFrame.UnregisterEvent, eventFrame, event)
end

eventFrame:SetScript("OnEvent", function(_, event, ...)
	local list = eventHandlers[event]
	if not list then return end
	for i = 1, #list do
		local h = list[i]
		if h then h.func(h.owner, event, ...) end
	end
end)

--- Register a game event. `self` is passed back as the first callback argument.
function ModuleBase:RegisterEvent(event, func)
	addHandler(eventHandlers, event, self, func, registerGameEvent)
end

function ModuleBase:UnregisterEvent(event)
	removeHandler(eventHandlers, event, self, unregisterGameEvent)
end

function ModuleBase:UnregisterAllEvents()
	for event in pairs(eventHandlers) do
		removeHandler(eventHandlers, event, self, unregisterGameEvent)
	end
	for msg in pairs(messageHandlers) do
		removeHandler(messageHandlers, msg, self)
	end
end

--- Internal messages. Used for addon-wide notifications such as
--- "BOOM_PROFILE_CHANGED" or "BOOM_ROSTER_UPDATED".
function ModuleBase:RegisterMessage(message, func)
	addHandler(messageHandlers, message, self, func)
end

function ModuleBase:UnregisterMessage(message)
	removeHandler(messageHandlers, message, self)
end

function ModuleBase:SendMessage(message, ...)
	return Boom:SendMessage(message, ...)
end

-- Boom itself is a valid handler owner too.
Boom.RegisterEvent      = ModuleBase.RegisterEvent
Boom.UnregisterEvent    = ModuleBase.UnregisterEvent
Boom.RegisterMessage    = ModuleBase.RegisterMessage
Boom.UnregisterMessage  = ModuleBase.UnregisterMessage

--- Broadcast an internal message.
---
--- Every handler is isolated. One module throwing must not stop the others from
--- hearing the message: BOOM_PROFILE_CHANGED alone is listened to by seven
--- modules, and an unguarded dispatch meant that a failure in the first of them
--- silently cancelled the options window refresh, the frame relayout and the
--- click-cast reapply - which looked to the user like the action they had just
--- taken had done nothing at all.
function Boom:SendMessage(message, ...)
	local list = messageHandlers[message]
	if not list then return end

	for i = 1, #list do
		local h = list[i]
		if h then
			local ok, err = pcall(h.func, h.owner, message, ...)
			if not ok then
				local owner = h.owner and h.owner.moduleName or "?"
				Boom:ReportError(owner .. " handling " .. tostring(message), err)
			end
		end
	end
end

--------------------------------------------------------------------------------
-- Combat-safe execution
--
-- Secure attributes, frame creation on secure headers and header re-anchoring
-- are all forbidden while the player is in combat. Anything touching those goes
-- through here.
--------------------------------------------------------------------------------

local combatQueue = {}
local combatQueueKeys = {}

--- Run `func` immediately when out of combat, otherwise once combat drops.
--- Passing `key` collapses repeated requests into a single deferred call.
function Boom:RunWhenSafe(func, key)
	if not InCombatLockdown() then
		return func()
	end

	if key then
		if combatQueueKeys[key] then
			combatQueueKeys[key] = func
			return
		end
		combatQueueKeys[key] = func
	else
		combatQueue[#combatQueue + 1] = func
	end
end

function Boom:IsSafe()
	return not InCombatLockdown()
end

local function flushCombatQueue()
	if #combatQueue > 0 then
		local pending = combatQueue
		combatQueue = {}
		for i = 1, #pending do
			local ok, err = pcall(pending[i])
			if not ok then Boom:Debug("deferred error:", err) end
		end
	end

	if next(combatQueueKeys) then
		local pending = combatQueueKeys
		combatQueueKeys = {}
		for _, func in pairs(pending) do
			local ok, err = pcall(func)
			if not ok then Boom:Debug("deferred error:", err) end
		end
	end
end

--------------------------------------------------------------------------------
-- Update scheduler
--
-- Buttons register themselves as dirty with a bitmask. The driver flushes the
-- whole set on the next frame by calling button:BoomUpdate(flags).
--------------------------------------------------------------------------------

local dirty = {}        -- [button] = flagMask
local dirtyCount = 0
local driver = CreateFrame("Frame", "BoomUpdateDriver")

--- Mark a unit button as needing work.
function Boom:MarkDirty(button, flags)
	if not button then return end
	local current = dirty[button]
	if current then
		dirty[button] = bor(current, flags)
	else
		dirty[button] = flags
		dirtyCount = dirtyCount + 1
		if not driver:IsShown() then driver:Show() end
	end
end

--- Mark every currently displayed button.
function Boom:MarkAllDirty(flags)
	local frames = Boom.Frames
	if not frames then return end
	for _, button in frames:IterateButtons() do
		Boom:MarkDirty(button, flags)
	end
end

--- Mark every button currently showing `unit`.
function Boom:MarkUnitDirty(unit, flags)
	local frames = Boom.Frames
	if not frames or not unit then return end
	local list = frames:GetButtonsForUnit(unit)
	if not list then return end
	for i = 1, #list do
		Boom:MarkDirty(list[i], flags)
	end
end

driver:Hide()
driver:SetScript("OnUpdate", function(self)
	if dirtyCount == 0 then
		self:Hide()
		return
	end

	-- Swap the table out first: an update handler is allowed to dirty a button
	-- again (e.g. range check scheduling), and we do not want to spin here.
	local batch = dirty
	dirty = {}
	dirtyCount = 0

	for button, flags in pairs(batch) do
		if button.BoomUpdate then
			-- Reported, not swallowed. A throw here aborts the rest of that
			-- button's indicators, so silence would mean half a frame quietly
			-- stops updating with nothing to show for it.
			local ok, err = pcall(button.BoomUpdate, button, flags)
			if not ok then Boom:ReportError("a unit frame update", err) end
		end
	end

	if dirtyCount == 0 then self:Hide() end
end)

--------------------------------------------------------------------------------
-- Timers / throttling
--------------------------------------------------------------------------------

local throttled = {}

--- Coalesce repeated calls under `key` into one call after `delay` seconds.
function Boom:Throttle(key, delay, func)
	if throttled[key] then
		throttled[key] = func
		return
	end
	throttled[key] = func
	C_Timer.After(delay, function()
		local pending = throttled[key]
		throttled[key] = nil
		if pending then
			local ok, err = pcall(pending)
			if not ok then Boom:Debug("throttled error:", err) end
		end
	end)
end

function Boom:After(delay, func)
	return C_Timer.After(delay, func)
end

function Boom:NewTicker(interval, func)
	return C_Timer.NewTicker(interval, func)
end

--------------------------------------------------------------------------------
-- Output
--------------------------------------------------------------------------------

local PREFIX = "|cff00d4ffBoom|r: "

function Boom:Print(...)
	local msg = ""
	for i = 1, select("#", ...) do
		msg = msg .. tostring((select(i, ...))) .. " "
	end
	DEFAULT_CHAT_FRAME:AddMessage(PREFIX .. msg)
end

Boom.debugMode = false

--------------------------------------------------------------------------------
-- Error reporting
--
-- Boom pcalls a lot: an indicator must not take the frame down, a widget must
-- not take the options window down. The failure mode of that discipline is
-- silence - something quietly does nothing and the user has no idea why.
--
-- So every caught error comes through here instead of vanishing into Debug.
-- It is announced once per unique error (a broken indicator would otherwise
-- print sixty times a second) and kept in the saved log for "/boom errors".
--------------------------------------------------------------------------------

local reportedErrors = {}
local errorBuffer = {}
Boom.errorBuffer = errorBuffer

local MAX_ERRORS = 25

function Boom:ReportError(context, err)
	local text = tostring(err)
	local key = tostring(context) .. "|" .. text

	local store = (Boom.global and Boom.global.errorLog) or errorBuffer
	store[#store + 1] = { context = tostring(context), err = text, version = Boom.VERSION }
	while #store > MAX_ERRORS do tremove(store, 1) end

	if not reportedErrors[key] then
		reportedErrors[key] = true
		DEFAULT_CHAT_FRAME:AddMessage(format(
			"|cffff4040Boom error|r in %s: %s |cff888888(/boom errors)|r",
			tostring(context), text))
	end
end

--- Something Boom noticed and handled, which the user may nonetheless want to
--- know about and can act on.
---
--- Deliberately NOT ReportError. A red "Boom error" line reads as a crash, and
--- the reader has no way to tell the difference between "the addon is broken"
--- and "the font you picked is not installed, so the default is being used" -
--- which is a working outcome with a one-click remedy. Announcing the second one
--- in the same voice as the first is how a healthy addon looks broken.
---
--- Still recorded in the same log, flagged, so /boom errors can show both while
--- keeping them apart.
function Boom:ReportNotice(context, message, advice)
	local text = tostring(message)
	local key = "notice|" .. tostring(context) .. "|" .. text

	local store = (Boom.global and Boom.global.errorLog) or errorBuffer
	store[#store + 1] = {
		context = tostring(context),
		err     = text,
		version = Boom.VERSION,
		notice  = true,
	}
	while #store > MAX_ERRORS do tremove(store, 1) end

	if not reportedErrors[key] then
		reportedErrors[key] = true
		DEFAULT_CHAT_FRAME:AddMessage(format("|cffffb020Boom|r: %s", text))
		if advice then
			DEFAULT_CHAT_FRAME:AddMessage("   |cff888888" .. tostring(advice) .. "|r")
		end
	end
end

--- Move anything caught before SavedVariables existed into the saved log.
function Boom:FlushErrorBuffer()
	if not Boom.global then return end
	Boom.global.errorLog = Boom.global.errorLog or {}

	for i = 1, #errorBuffer do
		Boom.global.errorLog[#Boom.global.errorLog + 1] = errorBuffer[i]
	end
	wipe(errorBuffer)

	while #Boom.global.errorLog > MAX_ERRORS do
		tremove(Boom.global.errorLog, 1)
	end
end

function Boom:Debug(...)
	if not Boom.debugMode then return end
	local msg = ""
	for i = 1, select("#", ...) do
		msg = msg .. tostring((select(i, ...))) .. " "
	end
	DEFAULT_CHAT_FRAME:AddMessage("|cff888888[Boom dbg]|r " .. msg)
end

--------------------------------------------------------------------------------
-- Blocked-action diagnostics
--
-- "Boom has been blocked from an action only available to the Blizzard UI" is
-- the client telling us that tainted code reached a protected function. The
-- popup itself names the addon but not the function, which is the one piece of
-- information needed to fix it - so we capture it ourselves.
--
-- The event fires synchronously at the moment of the blocked call, carrying the
-- addon name and the protected function's name. We record that plus the Lua
-- stack and persist it, so the cause can be read back afterwards with
-- "/boom blocked" instead of being reconstructed by guesswork.
--------------------------------------------------------------------------------

local blockedBuffer = {}
Boom.blockedBuffer = blockedBuffer

local MAX_BLOCKED = 20

local function recordBlocked(event, addon, func)
	-- Everything is recorded, including blocks the client attributes to another
	-- addon: taint spreads, and the name in the popup is the last addon to touch
	-- the value, which is not always the one that caused it.
	local entry = {
		event   = event,
		addon   = addon or "?",
		func    = func or "?",
		combat  = InCombatLockdown() and true or false,
		stack   = debugstack(3, 24, 24),
		version = Boom.VERSION,
	}

	local store = (Boom.global and Boom.global.blockedLog) or blockedBuffer
	store[#store + 1] = entry
	while #store > MAX_BLOCKED do tremove(store, 1) end

	-- One line, once per unique function, so a repeating block cannot spam.
	Boom.seenBlocked = Boom.seenBlocked or {}
	local key = entry.addon .. ":" .. entry.func
	if not Boom.seenBlocked[key] then
		Boom.seenBlocked[key] = true
		DEFAULT_CHAT_FRAME:AddMessage(format(
			"|cffff4040Boom diag:|r blocked |cffffd100%s|r by |cffffd100%s|r (%s). Run |cff00d4ff/boom blocked|r.",
			entry.func, entry.addon, entry.combat and "in combat" or "out of combat"))
	end
end

--- Move anything captured before SavedVariables existed into the saved log.
function Boom:FlushBlockedBuffer()
	if not Boom.global then return end
	Boom.global.blockedLog = Boom.global.blockedLog or {}

	for i = 1, #blockedBuffer do
		Boom.global.blockedLog[#Boom.global.blockedLog + 1] = blockedBuffer[i]
	end
	wipe(blockedBuffer)

	while #Boom.global.blockedLog > MAX_BLOCKED do
		tremove(Boom.global.blockedLog, 1)
	end
end

local blockedWatcher = CreateFrame("Frame")
blockedWatcher:RegisterEvent("ADDON_ACTION_BLOCKED")
blockedWatcher:RegisterEvent("ADDON_ACTION_FORBIDDEN")
blockedWatcher:SetScript("OnEvent", function(_, event, addon, func)
	local ok, err = pcall(recordBlocked, event, addon, func)
	if not ok then
		-- Never let the diagnostic itself become the problem.
		DEFAULT_CHAT_FRAME:AddMessage("|cffff4040Boom diagnostics error:|r " .. tostring(err))
	end
end)

--------------------------------------------------------------------------------
-- Lifecycle
--
-- ADDON_LOADED  -> SavedVariables exist -> module:OnInitialize()
-- PLAYER_LOGIN  -> the world is ready   -> module:OnEnable()
--------------------------------------------------------------------------------

Boom.initialized = false
Boom.enabled = false

local function initializeModules()
	if Boom.initialized then return end
	Boom.initialized = true

	for module in Boom:IterateModules() do
		if module.OnInitialize then
			local ok, err = pcall(module.OnInitialize, module)
			if not ok then
				Boom:Print("|cffff4040" .. module.moduleName .. " init failed:|r " .. tostring(err))
			end
		end
	end

	-- Config has created Boom.global by now, so anything caught during load can
	-- move into the saved logs.
	Boom:FlushBlockedBuffer()
	Boom:FlushErrorBuffer()
end

local function enableModules()
	if Boom.enabled then return end
	Boom.enabled = true

	for module in Boom:IterateModules() do
		if module.OnEnable then
			local ok, err = pcall(module.OnEnable, module)
			if not ok then
				Boom:Print("|cffff4040" .. module.moduleName .. " enable failed:|r " .. tostring(err))
			else
				module.enabled = true
			end
		end
	end

	Boom:SendMessage("BOOM_ENABLED")
end

local bootstrap = CreateFrame("Frame")
bootstrap:RegisterEvent("ADDON_LOADED")
bootstrap:RegisterEvent("PLAYER_LOGIN")
bootstrap:RegisterEvent("PLAYER_REGEN_ENABLED")
bootstrap:SetScript("OnEvent", function(self, event, arg1)
	if event == "ADDON_LOADED" then
		if arg1 == ADDON_NAME then
			initializeModules()
			self:UnregisterEvent("ADDON_LOADED")
		end
	elseif event == "PLAYER_LOGIN" then
		initializeModules()
		enableModules()
		self:UnregisterEvent("PLAYER_LOGIN")
	elseif event == "PLAYER_REGEN_ENABLED" then
		flushCombatQueue()
		Boom:SendMessage("BOOM_COMBAT_ENDED")
	end
end)
