-- Portal Authority — modules/framework.lua
-- Shared module framework, shared helpers, and module event dispatcher.
--
-- Depends on: PortalAuthority
-- Exposes:    PortalAuthority.Modules, PortalAuthority.ModulesInternal shared helpers, shared dispatcher
-- DB keys:    PortalAuthorityDB.modules*
-- Events:     shared module event dispatcher registrations
-- Search key: none

-- Portal Authority
-- Mythic+ modules (session-level unlock/test state + Battle Rez Timer).

PortalAuthority = PortalAuthority or {}
PortalAuthority.Modules = PortalAuthority.Modules or {}

local Modules = PortalAuthority.Modules
Modules.registry = Modules.registry or {}
Modules.optionsHooks = Modules.optionsHooks or {}
Modules.unlock = false
Modules.testMode = false
Modules.modules2TestMode = false
Modules.timersTestMode = false

local function trim(text)
    if type(hasanysecretvalues) == "function" then
        local ok, hasSecret = pcall(hasanysecretvalues, text)
        if ok and hasSecret then
            return ""
        end
    end
    if type(text) ~= "string" then
        return ""
    end
    local cleaned = text:gsub("^%s+", ""):gsub("%s+$", "")
    return cleaned
end

local function clampNumber(value, fallback, min, max)
    value = tonumber(value) or fallback
    if min and value < min then value = min end
    if max and value > max then value = max end
    return value
end

local function PA_Num(v)
    if v == nil then return 0 end
    -- stringify first to strip secret-number wrapper, then tonumber
    local n = tonumber(tostring(v))
    if n == nil then return 0 end
    return n
end

local function IT_HasSecretValues(...)
    if type(hasanysecretvalues) == "function" then
        local ok, hasSecret = pcall(hasanysecretvalues, ...)
        if ok and hasSecret then
            return true
        end
    end
    return false
end

local function IT_IsUsablePlainString(value)
    if type(value) ~= "string" then
        return false
    end
    if IT_HasSecretValues(value) then
        return false
    end
    return value ~= ""
end

local function IT_IsUsablePlainBoolean(value)
    return type(value) == "boolean" and not IT_HasSecretValues(value)
end

local function IT_IsUsablePlainNumber(value)
    if type(value) ~= "number" then
        return false
    end
    if IT_HasSecretValues(value) then
        return false
    end
    return value == value and value > -math.huge and value < math.huge
end

local function IT_NormalizeSafeString(value)
    if not IT_IsUsablePlainString(value) then
        return nil
    end
    return value
end

local function IT_NormalizeSpellID(value)
    if IT_IsUsablePlainNumber(value) then
        local numeric = math.floor(value)
        return numeric > 0 and numeric or 0
    end
    if IT_IsUsablePlainString(value) then
        local numeric = tonumber(value)
        if type(numeric) == "number" and numeric == numeric and numeric > 0 and numeric < math.huge then
            return math.floor(numeric)
        end
    end
    return 0
end

local function IT_SafeStringsEqual(left, right)
    local normalizedLeft = IT_NormalizeSafeString(left)
    local normalizedRight = IT_NormalizeSafeString(right)
    return normalizedLeft ~= nil and normalizedRight ~= nil and normalizedLeft == normalizedRight
end

local IT_SafeUnitGUID

local function PA_CanSafelyInspectUnit(unit)
    if type(unit) ~= "string" or unit == "" or not UnitExists(unit) then
        return false
    end
    if UnitIsConnected and not UnitIsConnected(unit) then
        return false
    end
    if type(CanInspect) ~= "function" then
        return true
    end
    local ok, canInspect = pcall(CanInspect, unit, true)
    if not ok then
        ok, canInspect = pcall(CanInspect, unit)
    end
    return ok and canInspect and true or false
end

local function PA_ServerNow()
    if type(GetServerTime) == "function" then
        return PA_Num(GetServerTime())
    end
    if type(time) == "function" then
        return PA_Num(time())
    end
    return PA_Num(GetTime())
end

local function PA_PerfBegin(scopeName, explicitState)
    if PortalAuthority and PortalAuthority.PerfBegin then
        return PortalAuthority:PerfBegin(scopeName, explicitState)
    end
    return nil, nil
end

local function PA_PerfEnd(scopeName, startedAt, stateLabel)
    if startedAt ~= nil and PortalAuthority and PortalAuthority.PerfEnd then
        PortalAuthority:PerfEnd(scopeName, startedAt, stateLabel)
    end
end

local function PA_CpuDiagCount(scopeName, detailKey)
    if PortalAuthority and PortalAuthority.CpuDiagCount then
        PortalAuthority:CpuDiagCount(scopeName, detailKey)
    end
end

local function PA_CpuDiagRecordModuleEvent(eventScope)
    if PortalAuthority and PortalAuthority.CpuDiagRecordModuleEvent then
        PortalAuthority:CpuDiagRecordModuleEvent(eventScope)
    end
end

local function PA_CpuDiagRecordDispatcherEvent(dispatcherName, eventName)
    if PortalAuthority and PortalAuthority.CpuDiagRecordDispatcherEvent then
        PortalAuthority:CpuDiagRecordDispatcherEvent(dispatcherName, eventName)
    end
end

local function PA_CpuDiagRecordUnitCallback(eventName)
    if PortalAuthority and PortalAuthority.CpuDiagRecordUnitCallback then
        PortalAuthority:CpuDiagRecordUnitCallback(eventName)
    end
end

local function PA_CpuDiagRecordTrigger(triggerKey)
    if PortalAuthority and PortalAuthority.CpuDiagRecordTrigger then
        PortalAuthority:CpuDiagRecordTrigger(triggerKey)
    end
end

local function PA_CpuDiagApplyVisibility(target, frame, naturalVisible)
    if not frame then
        return false
    end
    if PortalAuthority and PortalAuthority.CpuDiagApplyVisibility then
        return PortalAuthority:CpuDiagApplyVisibility(target, frame, naturalVisible)
    end
    if naturalVisible then
        frame:Show()
    else
        frame:Hide()
    end
    return naturalVisible and true or false
end

local function PA_CpuDiagIsFrameConsideredShown(target, frame)
    if frame and frame.IsShown and frame:IsShown() then
        return true
    end
    if PortalAuthority and PortalAuthority.CpuDiagIsNaturallyVisible then
        return PortalAuthority:CpuDiagIsNaturallyVisible(target)
    end
    return false
end

local function PA_IsUiSurfaceGateEnabled()
    return PortalAuthority and PortalAuthority.IsUiSurfaceGateEnabled and PortalAuthority:IsUiSurfaceGateEnabled() or false
end

local function PA_IsSuppressedUiSurfaceModule(moduleOrKey)
    if not PA_IsUiSurfaceGateEnabled() then
        return false
    end
    local key = type(moduleOrKey) == "table" and moduleOrKey.key or moduleOrKey
    return key == "timers" or key == "interruptTracker"
end

local function PA_HideTooltipIfOwnedBy(owner)
    if not GameTooltip then
        return
    end
    if owner == nil then
        return
    end
    local currentOwner = GameTooltip.GetOwner and GameTooltip:GetOwner() or nil
    if currentOwner ~= nil and currentOwner == owner then
        GameTooltip:Hide()
    end
end

function Modules:Register(module)
    if not module or not module.key then
        return
    end
    self.registry[module.key] = module
end

function Modules:ForEach(callback)
    for _, module in pairs(self.registry) do
        callback(module)
    end
end

function Modules:RegisterOptionsHooks(hooks)
    self.optionsHooks = self.optionsHooks or {}
    if type(hooks) ~= "table" then
        return
    end
    for key, value in pairs(hooks) do
        self.optionsHooks[key] = value
    end
end

function Modules:NotifyPositionChanged(moduleKey, x, y)
    local hooks = self.optionsHooks
    if not hooks then
        return
    end

    if moduleKey == "timers" and type(hooks.onTimersPositionChanged) == "function" then
        hooks.onTimersPositionChanged(x, y)
        return
    end

    if moduleKey == "interruptTracker" and type(hooks.onInterruptTrackerPositionChanged) == "function" then
        hooks.onInterruptTrackerPositionChanged(x, y)
    end
end

function Modules:SetUnlocked(unlocked)
    self.unlock = not not unlocked

    if type(PortalAuthorityDB) == "table" then
        PortalAuthorityDB.modules = PortalAuthorityDB.modules or {}
        PortalAuthorityDB.modules.timers = PortalAuthorityDB.modules.timers or {}
        PortalAuthorityDB.modules.timers.locked = not self.unlock
        PortalAuthorityDB.modules.interruptTracker = PortalAuthorityDB.modules.interruptTracker or {}
        PortalAuthorityDB.modules.interruptTracker.locked = not self.unlock
    end

    local applied = {}
    local orderedKeys = { "timers", "interruptTracker" }
    for i = 1, #orderedKeys do
        local key = orderedKeys[i]
        local module = self.registry and self.registry[key] or nil
        if module and module.SetUnlocked then
            module:SetUnlocked(self.unlock)
            applied[key] = true
        end
    end

    for key, module in pairs(self.registry or {}) do
        if not applied[key] and module and module.SetUnlocked then
            module:SetUnlocked(self.unlock)
        end
    end
end

function Modules:SetTestMode(enabled)
    self.testMode = not not enabled
    self:ForEach(function(module)
        if module.SetTestModeEnabled then
            module:SetTestModeEnabled(self.testMode)
        end
        if module.EvaluateVisibility then
            module:EvaluateVisibility("test-toggle")
        end
    end)
end

function Modules:SetTimersTestMode(enabled)
    enabled = not not enabled
    if enabled and InCombatLockdown and InCombatLockdown() then
        return
    end

    self.timersTestMode = enabled
    self.modules2TestMode = enabled
    local module = self.registry and self.registry.timers
    if not module then
        return
    end

    local moduleFrame = module.frame or module.mainFrame
    if enabled and module.IsEnabled and module:IsEnabled() and module.Initialize and not moduleFrame then
        module:Initialize()
    end

    if module.SetUnlocked then
        local desiredUnlocked = module.GetDB and (not not (module:GetDB().locked == false)) or (module.unlocked and true or false)
        module:SetUnlocked(desiredUnlocked)
    end

    if module.EvaluateVisibility then
        module:EvaluateVisibility("timers-test-toggle")
    end
end

function Modules:SetModules2TestMode(enabled)
    self:SetTimersTestMode(enabled)
end

PortalAuthority.ModulesInternal = PortalAuthority.ModulesInternal or {}
local ModulesInternal = PortalAuthority.ModulesInternal

IT_SafeUnitGUID = function(unit)
    local ok, guid = pcall(UnitGUID, unit)
    if ok and IT_IsUsablePlainString(guid) then
        return guid
    end
    return nil
end
local eventFrame = CreateFrame("Frame")
for _, event in ipairs({
    "PLAYER_ENTERING_WORLD",
    "ZONE_CHANGED_NEW_AREA",
    "GROUP_ROSTER_UPDATE",
    "CHALLENGE_MODE_START",
    "CHALLENGE_MODE_COMPLETED",
    "ENCOUNTER_START",
    "ENCOUNTER_END",
    "UNIT_AURA",
    "UNIT_POWER_UPDATE",
    "PLAYER_DEAD",
    "PLAYER_ALIVE",
    "PLAYER_UNGHOST",
    "PLAYER_ROLES_ASSIGNED",
    "PLAYER_SPECIALIZATION_CHANGED",
    "PLAYER_REGEN_ENABLED",
    "UNIT_PET",
    "INSPECT_READY",
    "SPELLS_CHANGED",
    "ROLE_CHANGED_INFORM",
    "UNIT_DIED",
}) do
    eventFrame:RegisterEvent(event)
end

local function PA_PerfModulesEventScope(event)
    if event == "UNIT_AURA" then
        return "modules_event_unit_aura"
    end
    if event == "UNIT_POWER_UPDATE" then
        return "modules_event_unit_power_update"
    end
    return "modules_event_other"
end

local function PA_CpuDiagModulesTriggerKey(event)
    if event == "PLAYER_ENTERING_WORLD" then
        return "enter_world"
    end
    if event == "ZONE_CHANGED_NEW_AREA" then
        return "zone_new_area"
    end
    if event == "PLAYER_SPECIALIZATION_CHANGED" then
        return "spec_change"
    end
    if event == "CHALLENGE_MODE_COMPLETED" then
        return "challenge_complete"
    end
    return nil
end

eventFrame:SetScript("OnEvent", function(_, event, ...)
    PA_CpuDiagRecordDispatcherEvent("modules", event)
    local triggerKey = PA_CpuDiagModulesTriggerKey(event)
    if triggerKey then
        PA_CpuDiagRecordTrigger(triggerKey)
    end
    local dispatchStart, dispatchState = PA_PerfBegin("modules_event_dispatch")
    local eventScope = PA_PerfModulesEventScope(event)
    PA_CpuDiagRecordModuleEvent(eventScope)
    local eventStart, eventState = PA_PerfBegin(eventScope, dispatchState)
    local function finish(...)
        PA_PerfEnd(eventScope, eventStart, eventState)
        PA_PerfEnd("modules_event_dispatch", dispatchStart, dispatchState)
        return ...
    end

    local eventArgCount = select("#", ...)
    local eventArgs = { ... }
    if Modules.timersTestMode and (event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" or event == "CHALLENGE_MODE_START") then
        if type(Modules.SetTimersTestMode) == "function" then
            Modules:SetTimersTestMode(false)
        else
            Modules.timersTestMode = false
        end
    end
    Modules:ForEach(function(module)
        if PA_IsSuppressedUiSurfaceModule(module) then
            return
        end
        if module.Initialize then
            local initStart, initState = PA_PerfBegin("modules_event_init", dispatchState)
            local okInit, errInit = pcall(module.Initialize, module)
            PA_PerfEnd("modules_event_init", initStart, initState)
            if not okInit then
                print("[PA] Module initialize error (" .. tostring(module.key or "?") .. "): " .. tostring(errInit))
                return
            end
        end
        if (event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" or event == "GROUP_ROSTER_UPDATE") and module.PickFallbackSpellID then
            local okPick, errPick = pcall(module.PickFallbackSpellID, module)
            if not okPick then
                print("[PA] Module fallback error (" .. tostring(module.key or "?") .. "): " .. tostring(errPick))
            end
        end
        local isRapidAuraPower = (event == "UNIT_AURA" or event == "UNIT_POWER_UPDATE")
        if module.EvaluateVisibility and not isRapidAuraPower and not module.skipSharedAutoEvaluateVisibility then
            local visStart, visState = PA_PerfBegin("modules_event_evaluate_visibility", dispatchState)
            local okVis, errVis = pcall(module.EvaluateVisibility, module, event)
            PA_PerfEnd("modules_event_evaluate_visibility", visStart, visState)
            if not okVis then
                print("[PA] Module visibility error (" .. tostring(module.key or "?") .. "): " .. tostring(errVis))
            end
        end
        if module.OnEvent then
            local onEventStart, onEventState = PA_PerfBegin("modules_event_on_event", dispatchState)
            local okEvent, errEvent = pcall(module.OnEvent, module, event, unpack(eventArgs, 1, eventArgCount))
            PA_PerfEnd("modules_event_on_event", onEventStart, onEventState)
            if not okEvent then
                print("[PA] Module event error (" .. tostring(module.key or "?") .. "): " .. tostring(errEvent))
            end
        end
    end)
    return finish()
end)

ModulesInternal.trim = trim
ModulesInternal.clampNumber = clampNumber
ModulesInternal.PA_Num = PA_Num
ModulesInternal.IT_HasSecretValues = IT_HasSecretValues
ModulesInternal.IT_IsUsablePlainString = IT_IsUsablePlainString
ModulesInternal.IT_IsUsablePlainBoolean = IT_IsUsablePlainBoolean
ModulesInternal.IT_IsUsablePlainNumber = IT_IsUsablePlainNumber
ModulesInternal.IT_NormalizeSafeString = IT_NormalizeSafeString
ModulesInternal.IT_NormalizeSpellID = IT_NormalizeSpellID
ModulesInternal.IT_SafeStringsEqual = IT_SafeStringsEqual
ModulesInternal.IT_SafeUnitGUID = IT_SafeUnitGUID
ModulesInternal.PA_CanSafelyInspectUnit = PA_CanSafelyInspectUnit
ModulesInternal.PA_ServerNow = PA_ServerNow
ModulesInternal.PA_PerfBegin = PA_PerfBegin
ModulesInternal.PA_PerfEnd = PA_PerfEnd
ModulesInternal.PA_CpuDiagCount = PA_CpuDiagCount
ModulesInternal.PA_CpuDiagRecordModuleEvent = PA_CpuDiagRecordModuleEvent
ModulesInternal.PA_CpuDiagRecordDispatcherEvent = PA_CpuDiagRecordDispatcherEvent
ModulesInternal.PA_CpuDiagRecordUnitCallback = PA_CpuDiagRecordUnitCallback
ModulesInternal.PA_CpuDiagRecordTrigger = PA_CpuDiagRecordTrigger
ModulesInternal.PA_CpuDiagApplyVisibility = PA_CpuDiagApplyVisibility
ModulesInternal.PA_CpuDiagIsFrameConsideredShown = PA_CpuDiagIsFrameConsideredShown
ModulesInternal.PA_IsUiSurfaceGateEnabled = PA_IsUiSurfaceGateEnabled
ModulesInternal.PA_IsSuppressedUiSurfaceModule = PA_IsSuppressedUiSurfaceModule
ModulesInternal.PA_HideTooltipIfOwnedBy = PA_HideTooltipIfOwnedBy
