-- Portal Authority — modules/timers.lua
-- Timers module runtime, migrations, display logic, and visibility handling.
--
-- Depends on: PortalAuthority.Modules; PortalAuthority.ModulesInternal
-- Exposes:    public Timers module; Modules.registry.timers
-- DB keys:    PortalAuthorityDB.modules.brezTimer*, PortalAuthorityDB.modules.timers*
-- Events:     shared module dispatcher only
-- Search key: none

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

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

do

local BrezTimer = {
    key = "brezTimer",
    fallbackSpellCandidates = { 20484, 61999, 20707 },
}

function BrezTimer:GetAuthoritativeState()
    if C_PartyInfo and C_PartyInfo.GetAvailableBattleResurrectionCharges and C_PartyInfo.GetBattleResurrectionChargeCooldown then
        local available = PA_Num(C_PartyInfo.GetAvailableBattleResurrectionCharges())
        available = math.max(0, math.floor(available))
        local start, cdDur = C_PartyInfo.GetBattleResurrectionChargeCooldown()
        start = PA_Num(start)
        cdDur = PA_Num(cdDur)
        local remaining = 0
        if cdDur > 0 then
            remaining = math.max(0, (start + cdDur) - GetTime())
        end
        return {
            charges = available,
            remaining = remaining,
            source = "partyinfo",
        }
    end

    return self:GetFallbackState()
end

function BrezTimer:PickFallbackSpellID()
    local selected = nil

    local function getCharges(spellID)
        if C_Spell and C_Spell.GetSpellCharges then
            local charges = C_Spell.GetSpellCharges(spellID)
            if type(charges) == "number" then
                return charges
            end
        end
        if GetSpellCharges then
            local charges = GetSpellCharges(spellID)
            if type(charges) == "number" then
                return charges
            end
        end
        return nil
    end

    local function isKnownSpell(spellID)
        if C_Spell and C_Spell.GetSpellName then
            return C_Spell.GetSpellName(spellID) ~= nil
        end
        if IsSpellKnownOrOverridesKnown then
            return IsSpellKnownOrOverridesKnown(spellID)
        end
        return false
    end

    for _, spellID in ipairs(self.fallbackSpellCandidates) do
        local charges = getCharges(spellID)
        if charges ~= nil then
            selected = spellID
            break
        end
        if isKnownSpell(spellID) then
            selected = spellID
            break
        end
    end

    self.fallbackSpellID = selected
end

function BrezTimer:GetFallbackState()
    if not self.fallbackSpellID then
        self:PickFallbackSpellID()
    end

    local spellID = self.fallbackSpellID or 20484

    local charges = nil
    local start, duration = nil, nil

    if C_Spell and C_Spell.GetSpellCharges then
        local chargesInfo = C_Spell.GetSpellCharges(spellID)
        if type(chargesInfo) == "table" then
            if chargesInfo.currentCharges ~= nil then
                charges = PA_Num(chargesInfo.currentCharges)
                start = PA_Num(chargesInfo.cooldownStartTime)
                duration = PA_Num(chargesInfo.cooldownDuration)
            end
        else
            local currentCharges, maxCharges, chargeStart, chargeDuration = C_Spell.GetSpellCharges(spellID)
            if currentCharges ~= nil then
                charges = PA_Num(currentCharges)
                start = PA_Num(chargeStart)
                duration = PA_Num(chargeDuration)
            end
        end
    elseif GetSpellCharges then
        local currentCharges, maxCharges, chargeStart, chargeDuration = GetSpellCharges(spellID)
        if currentCharges ~= nil then
            charges = PA_Num(currentCharges)
            start = PA_Num(chargeStart)
            duration = PA_Num(chargeDuration)
        end
    end

    if charges == nil then
        charges = 0
    end

    if start == nil then
        if C_Spell and C_Spell.GetSpellCooldown then
            local info = C_Spell.GetSpellCooldown(spellID)
            if type(info) == "table" then
                start, duration = info.startTime, info.duration
            end
        elseif GetSpellCooldown then
            start, duration = GetSpellCooldown(spellID)
        end
    end

    local remaining = 0
    start = PA_Num(start)
    duration = PA_Num(duration)
    if duration > 0 then
        remaining = math.max(0, (start + duration) - GetTime())
    end

    return {
        charges = math.max(0, PA_Num(charges)),
        remaining = remaining,
        source = "fallback",
    }
end

function BrezTimer:ShouldShowLive()
    local _, instanceType, difficultyID = GetInstanceInfo()
    local inPartyDungeon = (instanceType == "party")
    local inMythicKeystone = (difficultyID == 8)
    local inChallenge = C_ChallengeMode and C_ChallengeMode.IsChallengeModeActive and C_ChallengeMode.IsChallengeModeActive()
    local inEncounter = C_InstanceEncounter and C_InstanceEncounter.IsEncounterInProgress and C_InstanceEncounter.IsEncounterInProgress()
    return not not (inPartyDungeon or inMythicKeystone or inChallenge or inEncounter)
end

function BrezTimer:IsMythicKeystoneActive()
    if C_PartyInfo and C_PartyInfo.IsChallengeModeActive then
        return not not C_PartyInfo.IsChallengeModeActive()
    end
    if C_ChallengeMode and C_ChallengeMode.IsChallengeModeActive then
        return not not C_ChallengeMode.IsChallengeModeActive()
    end
    return false
end


local function copyMissingRecursive(source, target)
    for key, value in pairs(source) do
        local existing = target[key]
        if type(value) == "table" then
            if existing == nil then
                target[key] = {}
                copyMissingRecursive(value, target[key])
            elseif type(existing) == "table" then
                copyMissingRecursive(value, existing)
            end
        elseif existing == nil then
            target[key] = value
        end
    end
end

local function MigrateModules2StackToTimers()
    PortalAuthorityDB = PortalAuthorityDB or {}
    PortalAuthorityDB.modules = PortalAuthorityDB.modules or {}
    PortalAuthorityDB.modules.timers = type(PortalAuthorityDB.modules.timers) == "table" and PortalAuthorityDB.modules.timers or {}
    local migrations = (PortalAuthority and PortalAuthority.GetOperationalMigrations and PortalAuthority:GetOperationalMigrations())
        or {}

    if migrations.modules2Stack_to_timers then
        return
    end

    local oldRef = PortalAuthorityDB.modules.modules2Stack
    local newRef = PortalAuthorityDB.modules.timers

    if type(oldRef) == "table" then
        copyMissingRecursive(oldRef, newRef)
    end

    migrations.modules2Stack_to_timers = true
end

local Timers = {
    key = "timers",
}

local ICON_EDGE_STYLE_SHARP = "Sharp (Zoomed)"
local ICON_EDGE_STYLE_FULL = "Square (Full)"
local ICON_EDGE_STYLE_MASK = "Interface\\CharacterFrame\\TempPortraitAlphaMask"

local PA_LUST = {
    EXHAUST_DEBUFFS = { 57723, 390435, 57724, 80354, 264689 },
    BUFFS = { 2825, 32182, 80353, 264667, 390386, 466904 },
    ACTIVE_SECONDS = 40,
    SATED_SECONDS = 600,
    AUDIO_PATH = "Interface\\AddOns\\PortalAuthority\\Media\\Audio\\bloodlust.ogg",
    SPEC_MARKSMANSHIP = 254,
    FALLBACK_COOLDOWNS = {
        [2825] = 300,
        [32182] = 300,
        [80353] = 300,
        [264667] = 360,
        [390386] = 300,
        [466904] = 360,
    },
    SUPPORTED_SPELLS = {},
    EXHAUST_LOOKUP = {},
}
for spellID in pairs(PA_LUST.FALLBACK_COOLDOWNS) do
    PA_LUST.SUPPORTED_SPELLS[spellID] = true
end
for _, spellID in ipairs(PA_LUST.EXHAUST_DEBUFFS) do
    PA_LUST.EXHAUST_LOOKUP[spellID] = true
end

local function PA_LustNormalizeNameText(value)
    if not IT_IsUsablePlainString(value) then
        return nil
    end
    local text = trim(value)
    if text == "" then
        return nil
    end
    return text
end

local function PA_LustNormalizeShortName(value)
    local normalized = PA_LustNormalizeNameText(value)
    if not normalized then
        return nil
    end
    local ok, shortName = pcall(Ambiguate, normalized, "short")
    return PA_LustNormalizeNameText(ok and shortName or normalized)
end

local function PA_LustNormalizeObservedUnitToken(unit)
    if unit == nil then
        return nil
    end
    local ok, text = pcall(tostring, unit)
    if not ok then
        return nil
    end
    if type(text) ~= "string" then
        return nil
    end
    text = text:gsub("^%s+", ""):gsub("%s+$", "")
    if text == "" then
        return nil
    end
    return text
end

local function PA_LustSafeUnitClass(unit)
    local ok, _, classFile = pcall(UnitClass, unit)
    if ok and IT_IsUsablePlainString(classFile) then
        return classFile
    end
    return nil
end

local function PA_LustSafeUnitName(unit)
    if not unit then
        return nil
    end
    if UnitFullName then
        local ok, name, realm = pcall(UnitFullName, unit)
        if ok and not IT_HasSecretValues(name, realm) then
            local normalizedName = PA_LustNormalizeNameText(name)
            local normalizedRealm = PA_LustNormalizeNameText(realm)
            if normalizedName then
                if normalizedRealm then
                    return normalizedName .. "-" .. normalizedRealm
                end
                return normalizedName
            end
        end
    end
    local ok, name = pcall(UnitName, unit)
    if ok and not IT_HasSecretValues(name) then
        return PA_LustNormalizeNameText(name)
    end
    return nil
end

local function PA_LustResolveCooldownSeconds(spellID)
    local id = PA_Num(spellID)
    if id <= 0 then
        return nil
    end
    if GetSpellBaseCooldown then
        local ok, cooldownMs = pcall(GetSpellBaseCooldown, id)
        local cooldownSeconds = ok and (PA_Num(cooldownMs) / 1000) or 0
        if cooldownSeconds > 0 then
            return cooldownSeconds
        end
    end
    return PA_Num(PA_LUST.FALLBACK_COOLDOWNS[id])
end

local function PA_LustGetSelfDirectSpellID(classFile)
    if classFile == "MAGE" then
        return 80353
    end
    if classFile == "SHAMAN" then
        local faction = UnitFactionGroup and UnitFactionGroup("player") or nil
        if faction == "Alliance" then
            return 32182
        end
        return 2825
    end
    if classFile == "EVOKER" then
        return 390386
    end
    return 0
end

function Timers:UpdateLustIcon()
    if not (self.lustRow and self.lustRow.icon) then return end
    local _, class = UnitClass("player")
    local tex = 136012
    if class == "SHAMAN" then
        local faction = UnitFactionGroup and UnitFactionGroup("player") or nil
        if faction == "Alliance" then
            tex = 132313
        else
            tex = 136012
        end
    elseif class == "MAGE" then
        tex = 458224
    elseif class == "HUNTER" then
        tex = 136224
    elseif class == "EVOKER" then
        tex = 4723908
    end
    self.lustRow.icon:SetTexture(tex)
end

function Timers:EnsureLustRuntime()
    self.lustSources = self.lustSources or {}
    self.lustRosterList = self.lustRosterList or {}
    self.lustRosterByName = self.lustRosterByName or {}
    self.lustHunterSpecs = self.lustHunterSpecs or {}
    self.lustInspectQueue = self.lustInspectQueue or {}
    self.lustInspectBusy = self.lustInspectBusy or false
    self.lustInspectUnit = self.lustInspectUnit or nil
    self.lustInspectTargetGUID = self.lustInspectTargetGUID or nil
    self.lustInspectTimeoutTimer = self.lustInspectTimeoutTimer or nil
end

local PA_LUST_INSPECT_TIMEOUT = 4.0

function Timers:CancelLustInspectTimeout()
    if self.lustInspectTimeoutTimer then
        self.lustInspectTimeoutTimer:Cancel()
        self.lustInspectTimeoutTimer = nil
    end
end

function Timers:ClearLustInspectState()
    self:CancelLustInspectTimeout()
    if self.lustInspectBusy or self.lustInspectUnit or self.lustInspectTargetGUID then
        pcall(ClearInspectPlayer)
    end
    self.lustInspectBusy = false
    self.lustInspectUnit = nil
    self.lustInspectTargetGUID = nil
end

function Timers:ScheduleLustInspectResume()
    if InCombatLockdown and InCombatLockdown() then
        return
    end
    self:ProcessLustInspectQueue()
end

function Timers:ArmLustInspectTimeout(expectedGUID)
    self:CancelLustInspectTimeout()
    self.lustInspectTimeoutTimer = C_Timer.NewTimer(PA_LUST_INSPECT_TIMEOUT, function()
        local activeGUID = IT_NormalizeSafeString(self.lustInspectTargetGUID)
        local targetGUID = IT_NormalizeSafeString(expectedGUID)
        if targetGUID and activeGUID and not IT_SafeStringsEqual(activeGUID, targetGUID) then
            return
        end
        self:ClearLustInspectState()
        self:ScheduleLustInspectResume()
    end)
end

function Timers:EnsureDB()
    MigrateModules2StackToTimers()
    PortalAuthorityDB = PortalAuthorityDB or {}
    PortalAuthorityDB.modules = PortalAuthorityDB.modules or {}
    PortalAuthorityDB.modules.timers = PortalAuthorityDB.modules.timers or {}
    local db = PortalAuthorityDB.modules.timers

    -- Seed Timers DB keys (missing-only; no overwrite of valid stored values).
    if db.x == nil then db.x = 0 end
    if db.y == nil then db.y = 0 end
    if db.locked == nil then db.locked = true end
    if db.brezEnabled == nil then db.brezEnabled = true end
    if db.lustEnabled == nil then db.lustEnabled = true end
    if db.lustSatedEndSound == nil then db.lustSatedEndSound = false end
    if db.healerEnabled == nil then db.healerEnabled = true end
    if db.cooldownSweepEnabled == nil then db.cooldownSweepEnabled = true end
    if db.fontPath == nil then db.fontPath = "" end
    if db.fontSize == nil then db.fontSize = 12 end
    if db.fontFlags == nil then db.fontFlags = "" end
    if db.iconSize == nil then db.iconSize = 18 end
    if db.iconTextSpacing == nil then db.iconTextSpacing = 8 end
    if db.iconOffsetX == nil then db.iconOffsetX = 0 end
    if db.iconOffsetY == nil then db.iconOffsetY = 0 end
    if db.iconAspect == nil then db.iconAspect = "1:1" end
    if db.iconEdgeStyle == nil then db.iconEdgeStyle = ICON_EDGE_STYLE_SHARP end

    db.x = math.floor(tonumber(db.x) or 0)
    db.y = math.floor(tonumber(db.y) or 0)
    db.locked = not not db.locked
    db.fontSize = math.floor(clampNumber(db.fontSize, 12, 8, 48))
    db.iconSize = math.floor(clampNumber(db.iconSize, 18, 12, 64))
    db.iconTextSpacing = math.floor(clampNumber(db.iconTextSpacing, 8, 0, 64))
    db.iconOffsetX = math.floor(clampNumber(db.iconOffsetX, 0, -50, 50))
    db.iconOffsetY = math.floor(clampNumber(db.iconOffsetY, 0, -50, 50))
    db.fontPath = trim(db.fontPath)
    db.fontFlags = trim(db.fontFlags)
    db.iconAspect = trim(db.iconAspect)
    if db.iconAspect == "" then db.iconAspect = "1:1" end
    if db.iconEdgeStyle == "Rounded Corners" then
        db.iconEdgeStyle = ICON_EDGE_STYLE_SHARP
    end
    if db.iconEdgeStyle ~= ICON_EDGE_STYLE_SHARP and db.iconEdgeStyle ~= ICON_EDGE_STYLE_FULL then
        db.iconEdgeStyle = ICON_EDGE_STYLE_SHARP
    end
    db.cooldownSweepEnabled = not not db.cooldownSweepEnabled
    db.lustSatedEndSound = not not db.lustSatedEndSound
    if db.lustActiveEndsAt == nil then db.lustActiveEndsAt = 0 end
    if db.lustSatedEndsAt == nil then db.lustSatedEndsAt = 0 end
    if db.lustNotSatedEndsAt == nil then db.lustNotSatedEndsAt = 0 end
    if db.lustLastCastAt == nil then db.lustLastCastAt = 0 end
    db.lustActiveEndsAt = tonumber(db.lustActiveEndsAt) or 0
    db.lustSatedEndsAt = tonumber(db.lustSatedEndsAt) or 0
    db.lustNotSatedEndsAt = tonumber(db.lustNotSatedEndsAt) or 0
    db.lustLastCastAt = tonumber(db.lustLastCastAt) or 0

    return db
end

function Timers:GetDB()
    return self:EnsureDB()
end

function Timers:IsEnabled()
    local db = self:GetDB()
    return db.enabled ~= false
end

function Timers:ApplyPosition()
    if not self.frame then return end
    local db = self:GetDB()
    self.frame:ClearAllPoints()
    self.frame:SetPoint("CENTER", UIParent, "CENTER", tonumber(db.x) or 0, tonumber(db.y) or 0)
end

function Timers:ResetPositionToCenter()
    local db = self:GetDB()
    db.x = 0
    db.y = 0
    self:ApplyPosition()
    Modules:NotifyPositionChanged(self.key, db.x, db.y)
    if self.EvaluateVisibility then
        self:EvaluateVisibility("reset-anchor")
    elseif self.Tick then
        self:Tick()
    end
end

function Timers:PersistPosition()
    local db = self:GetDB()
    local cx, cy = self.frame:GetCenter()
    local ux, uy = UIParent:GetCenter()
    if not cx or not cy or not ux or not uy then return end
    db.x = math.floor((cx - ux) + 0.5)
    db.y = math.floor((cy - uy) + 0.5)
    Modules:NotifyPositionChanged(self.key, db.x, db.y)
end

function Timers:NotifyDragPosition()
    local cx, cy = self.frame:GetCenter()
    local ux, uy = UIParent:GetCenter()
    if not cx or not cy or not ux or not uy then return end
    Modules:NotifyPositionChanged(self.key, math.floor((cx - ux) + 0.5), math.floor((cy - uy) + 0.5))
end

function Timers:ShouldShowLive()
    -- Match Brez gating behavior/context.
    return BrezTimer:ShouldShowLive()
end

function Timers:GetFont()
    local db = self:GetDB()
    local path = trim(db.fontPath)
    local size = db.fontSize or 12
    local flags = db.fontFlags or ""
    if path == "" and PortalAuthority.GetGlobalFontPath then
        local globalPath, globalFlags = PortalAuthority:GetGlobalFontPath()
        path = globalPath or ""
        if flags == "" then flags = globalFlags or "" end
    end
    if path == "" then
        path = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"
    end
    return path, size, flags
end

function Timers:GetIconDimensions()
    local db = self:GetDB()
    local base = db.iconSize or 18
    local ratio = trim(db.iconAspect)
    local w, h = ratio:match("^(%d+)%s*:%s*(%d+)$")
    w, h = tonumber(w), tonumber(h)
    if not w or not h or h == 0 then
        w, h = 1, 1
    end
    return math.max(1, base * (w / h)), math.max(1, base), db.iconTextSpacing or 8
end

function Timers:ApplyIconStyle()
    local db = self:GetDB()
    local style = db.iconEdgeStyle or ICON_EDGE_STYLE_SHARP

    local function applyToRow(row)
        if not row or not row.icon then return end
        local baseLeft, baseRight, baseTop, baseBottom = 0, 1, 0, 1
        if row.baseTexCoord then
            baseLeft = row.baseTexCoord[1] or 0
            baseRight = row.baseTexCoord[2] or 1
            baseTop = row.baseTexCoord[3] or 0
            baseBottom = row.baseTexCoord[4] or 1
        end

        if style == ICON_EDGE_STYLE_SHARP then
            local insetH = (baseRight - baseLeft) * 0.08
            local insetV = (baseBottom - baseTop) * 0.08
            row.icon:SetTexCoord(baseLeft + insetH, baseRight - insetH, baseTop + insetV, baseBottom - insetV)
        else
            row.icon:SetTexCoord(baseLeft, baseRight, baseTop, baseBottom)
        end

        local usedMask = false
        pcall(row.icon.SetMaskTexture, row.icon, nil)
        if style == ICON_EDGE_STYLE_ROUNDED and row.icon.SetMaskTexture then
            local ok = pcall(row.icon.SetMaskTexture, row.icon, ICON_EDGE_STYLE_MASK)
            usedMask = ok and (not row.icon.GetMaskTexture or row.icon:GetMaskTexture() ~= nil)
        end

        if not row.iconFallbackBG then
            local fallback = CreateFrame("Frame", nil, row, "BackdropTemplate")
            fallback:SetPoint("TOPLEFT", row.icon, "TOPLEFT", -1, 1)
            fallback:SetPoint("BOTTOMRIGHT", row.icon, "BOTTOMRIGHT", 1, -1)
            fallback:SetBackdrop({
                bgFile = "Interface/Buttons/WHITE8X8",
                edgeFile = "Interface/Tooltips/UI-Tooltip-Border",
                edgeSize = 8,
                insets = { left = 2, right = 2, top = 2, bottom = 2 },
            })
            fallback:SetBackdropColor(0, 0, 0, 0.25)
            fallback:SetBackdropBorderColor(0.15, 0.15, 0.15, 0.55)
            fallback:SetFrameLevel(math.max(0, row:GetFrameLevel() - 1))
            row.iconFallbackBG = fallback
        end

        local showFallback = (style == ICON_EDGE_STYLE_ROUNDED and not usedMask)
        row.iconFallbackBG:SetShown(showFallback)
    end

    applyToRow(self.rezRow)
    applyToRow(self.lustRow)
    applyToRow(self.healerRow)
end

function Timers:FindHealerUnit()
    if IsInRaid and IsInRaid() then
        local members = GetNumGroupMembers() or 0
        for i = 1, members do
            local unit = "raid" .. i
            if UnitExists(unit) and UnitGroupRolesAssigned(unit) == "HEALER" then
                return unit
            end
        end
    elseif IsInGroup and IsInGroup() then
        local members = GetNumSubgroupMembers() or 0
        for i = 1, members do
            local unit = "party" .. i
            if UnitExists(unit) and UnitGroupRolesAssigned(unit) == "HEALER" then
                return unit
            end
        end
    end
    if UnitGroupRolesAssigned("player") == "HEALER" then
        return "player"
    end
    return nil
end

function Timers:BuildLustRoster()
    self:EnsureLustRuntime()
    local rosterList = {}
    local rosterByName = {}

    local function addUnit(unit, isSelf)
        if not (unit and UnitExists(unit)) then
            return
        end
        local name = PA_LustSafeUnitName(unit)
        local classFile = PA_LustSafeUnitClass(unit)
        if not name or not classFile or rosterByName[name] then
            return
        end
        local petUnit = nil
        if isSelf then
            petUnit = "pet"
        else
            local partyIndex = tostring(unit):match("^party(%d+)$")
            if partyIndex then
                petUnit = "partypet" .. partyIndex
            end
        end
        local entry = {
            name = name,
            unit = unit,
            petUnit = petUnit,
            class = classFile,
            isSelf = isSelf and true or false,
        }
        rosterList[#rosterList + 1] = entry
        rosterByName[name] = entry
    end

    addUnit("player", true)
    for i = 1, 4 do
        addUnit("party" .. i, false)
    end

    self.lustRosterList = rosterList
    self.lustRosterByName = rosterByName
    return rosterList, rosterByName
end

function Timers:GetLustSource(name, classFile)
    if not name then
        return nil
    end
    self:EnsureLustRuntime()
    local source = self.lustSources[name]
    if not source then
        source = {
            name = name,
            class = classFile,
            spellID = 0,
            readyAt = 0,
            castAt = 0,
            specID = 0,
            lastSeenAt = 0,
        }
        self.lustSources[name] = source
    elseif classFile and classFile ~= "" then
        source.class = classFile
    end
    return source
end

function Timers:ClearLustObservedCast()
    self.lustLastObservedCast = nil
end

function Timers:ClearLustObservedSpellcast()
    self.lustLastObservedSpellcast = nil
end

function Timers:RecordLustObservedSpellcast(unit, spellID)
    self:EnsureLustRuntime()
    local resolvedSpellID = PA_Num(spellID)
    if resolvedSpellID <= 0 or not PA_LUST.SUPPORTED_SPELLS[resolvedSpellID] then
        return false
    end
    local unitToken = PA_LustNormalizeObservedUnitToken(unit)
    if not unitToken then
        return false
    end
    if not (unitToken == "player" or unitToken == "pet" or unitToken:match("^party%d+$") or unitToken:match("^partypet%d+$")) then
        return false
    end

    local now = PA_Num(GetTime())
    local record = self.lustLastObservedCast or {}
    if unitToken and record.unit ~= unitToken then
        record.name = nil
        record.class = nil
        record.shortName = nil
    end
    record.unit = unitToken or record.unit
    record.spellID = resolvedSpellID
    record.castAt = now
    if unitToken == "player" or unitToken == "pet" then
        record.isSelf = true
    elseif record.isSelf == nil then
        record.isSelf = false
    end

    self.lustLastObservedCast = record
    self.lustLastObservedSpellcast = {
        spellID = resolvedSpellID,
        castAt = now,
        unit = unitToken,
    }
    return true
end

function Timers:RecordLustObservedCast(entry, spellID)
    self:EnsureLustRuntime()
    if not entry then
        return
    end

    local record = self.lustLastObservedCast or {}
    record.unit = entry.unit or record.unit
    record.name = entry.name or record.name
    record.class = entry.class or record.class
    record.spellID = PA_Num(spellID) > 0 and PA_Num(spellID) or PA_Num(record.spellID)
    record.castAt = PA_Num(record.castAt) > 0 and PA_Num(record.castAt) or PA_Num(GetTime())
    record.isSelf = entry.isSelf and true or false
    if IT_IsUsablePlainString(record.name) then
        record.shortName = PA_LustNormalizeShortName(record.name) or record.shortName or record.name
    end

    self.lustLastObservedCast = record
end

function Timers:GetValidObservedLustCast()
    self:EnsureLustRuntime()
    local record = self.lustLastObservedCast
    if not record then
        return nil, 0
    end

    local now = PA_Num(GetTime())
    local castAt = PA_Num(record.castAt)
    if castAt <= 0 or castAt > now + 0.5 or (now - castAt) > PA_LUST.ACTIVE_SECONDS then
        self:ClearLustObservedCast()
        return nil, 0
    end

    local remaining = PA_LUST.ACTIVE_SECONDS - math.max(0, now - castAt)
    if remaining <= 0 then
        self:ClearLustObservedCast()
        return nil, 0
    end

    local resolvedSpellID = PA_Num(record.spellID)
    if resolvedSpellID <= 0 or not PA_LUST.SUPPORTED_SPELLS[resolvedSpellID] then
        self:ClearLustObservedCast()
        return nil, 0
    end

    local liveEntry = nil
    if IT_IsUsablePlainString(record.unit) then
        local ok, resolvedEntry = pcall(self.GetLustEntryForObservedUnit, self, record.unit)
        if ok then
            liveEntry = resolvedEntry
        end
    end
    if liveEntry then
        self:RecordLustObservedCast(liveEntry, record.spellID)
        record = self.lustLastObservedCast or record
    elseif IT_IsUsablePlainString(record.name) then
        self:BuildLustRoster()
        local rosterEntry = self.lustRosterByName and self.lustRosterByName[record.name] or nil
        if rosterEntry then
            record.name = rosterEntry.name or record.name
            record.class = rosterEntry.class or record.class
            record.shortName = PA_LustNormalizeShortName(rosterEntry.name or record.name) or record.shortName or rosterEntry.name
        end
    end

    if IT_IsUsablePlainString(record.name) then
        record.shortName = PA_LustNormalizeShortName(record.name) or record.shortName or record.name
    end
    self.lustLastObservedCast = record
    return record, remaining
end

function Timers:ClearLustSourceReadiness(name)
    if not name then
        return
    end
    local source = self.lustSources and self.lustSources[name] or nil
    if not source then
        return
    end
    source.spellID = 0
    source.readyAt = 0
    source.castAt = 0
    source.lastSeenAt = PA_ServerNow()
end

function Timers:RefreshHunterSpecState(name, specID, clearUnknown)
    if not name then
        return
    end
    local normalizedSpecID = PA_Num(specID)
    if normalizedSpecID > 0 then
        self.lustHunterSpecs[name] = normalizedSpecID
    else
        self.lustHunterSpecs[name] = nil
    end

    local source = self.lustSources and self.lustSources[name] or nil
    if not source or source.class ~= "HUNTER" then
        return
    end
    source.specID = normalizedSpecID

    local spellID = PA_Num(source.spellID)
    if spellID <= 0 then
        return
    end
    if spellID == 466904 and normalizedSpecID ~= PA_LUST.SPEC_MARKSMANSHIP then
        self:ClearLustSourceReadiness(name)
    elseif spellID == 264667 and normalizedSpecID == PA_LUST.SPEC_MARKSMANSHIP then
        self:ClearLustSourceReadiness(name)
    elseif clearUnknown and normalizedSpecID <= 0 and (spellID == 466904 or spellID == 264667) then
        self:ClearLustSourceReadiness(name)
    end
end

function Timers:HandleLustHunterPetChanged(ownerUnit)
    if not ownerUnit then
        return
    end
    self:BuildLustRoster()
    local ownerName = PA_LustSafeUnitName(ownerUnit)
    local entry = ownerName and self.lustRosterByName and self.lustRosterByName[ownerName] or nil
    if not entry or entry.class ~= "HUNTER" then
        return
    end

    local source = self.lustSources and self.lustSources[ownerName] or nil
    if source and PA_Num(source.spellID) == 264667 then
        self:ClearLustSourceReadiness(ownerName)
    end
end

function Timers:CleanupLustRosterState()
    self:EnsureLustRuntime()
    self:BuildLustRoster()
    local current = self.lustRosterByName or {}

    for name in pairs(self.lustSources or {}) do
        if not current[name] then
            self.lustSources[name] = nil
        end
    end
    for name in pairs(self.lustHunterSpecs or {}) do
        if not current[name] then
            self.lustHunterSpecs[name] = nil
        end
    end

    if self.lustInspectQueue and #self.lustInspectQueue > 0 then
        local nextQueue = {}
        for _, unit in ipairs(self.lustInspectQueue) do
            local queuedName = UnitExists(unit) and PA_LustSafeUnitName(unit) or nil
            local rosterEntry = queuedName and current[queuedName] or nil
            if rosterEntry and rosterEntry.class == "HUNTER" and rosterEntry.unit == unit then
                nextQueue[#nextQueue + 1] = unit
            end
        end
        self.lustInspectQueue = nextQueue
    end

    if self.lustInspectUnit then
        local inspectName = UnitExists(self.lustInspectUnit) and PA_LustSafeUnitName(self.lustInspectUnit) or nil
        local rosterEntry = inspectName and current[inspectName] or nil
        if not rosterEntry or rosterEntry.unit ~= self.lustInspectUnit then
            self:ClearLustInspectState()
        end
    end
end

function Timers:ProcessLustInspectQueue()
    self:EnsureLustRuntime()
    self:BuildLustRoster()
    if self.lustInspectBusy then
        return
    end
    if InCombatLockdown and InCombatLockdown() then
        return
    end
    while #self.lustInspectQueue > 0 do
        local unit = table.remove(self.lustInspectQueue, 1)
        if UnitExists(unit) and UnitIsConnected(unit) then
            local name = PA_LustSafeUnitName(unit)
            local guid = IT_SafeUnitGUID(unit)
            local rosterEntry = name and self.lustRosterByName and self.lustRosterByName[name] or nil
            if rosterEntry and rosterEntry.class == "HUNTER" and not rosterEntry.isSelf and PA_Num(self.lustHunterSpecs[name]) <= 0 and PA_CanSafelyInspectUnit(unit) then
                self.lustInspectBusy = true
                self.lustInspectUnit = unit
                self.lustInspectTargetGUID = guid
                local ok = pcall(NotifyInspect, unit)
                if ok then
                    self:ArmLustInspectTimeout(guid)
                    return
                end
                self:ClearLustInspectState()
            end
        end
    end
end

function Timers:QueueLustHunterInspect(targetUnit)
    self:EnsureLustRuntime()
    self:BuildLustRoster()

    local function queueUnit(unit)
        if not (unit and unit ~= "player" and UnitExists(unit)) then
            return
        end
        local name = PA_LustSafeUnitName(unit)
        local rosterEntry = name and self.lustRosterByName and self.lustRosterByName[name] or nil
        if not rosterEntry or rosterEntry.class ~= "HUNTER" or rosterEntry.isSelf or PA_Num(self.lustHunterSpecs[name]) > 0 then
            return
        end
        if self.lustInspectUnit == unit then
            return
        end
        for _, queuedUnit in ipairs(self.lustInspectQueue or {}) do
            if queuedUnit == unit then
                return
            end
        end
        self.lustInspectQueue[#self.lustInspectQueue + 1] = unit
    end

    if targetUnit then
        queueUnit(targetUnit)
    else
        for _, entry in ipairs(self.lustRosterList or {}) do
            if entry.class == "HUNTER" and not entry.isSelf then
                queueUnit(entry.unit)
            end
        end
    end

    self:ProcessLustInspectQueue()
end

function Timers:HandleLustInspectReady(inspectedGUID)
    if not self.lustInspectBusy or not self.lustInspectUnit then
        return
    end
    local activeGUID = IT_NormalizeSafeString(self.lustInspectTargetGUID)
    local readyGUID = IT_NormalizeSafeString(inspectedGUID)
    if activeGUID and (not readyGUID or not IT_SafeStringsEqual(activeGUID, readyGUID)) then
        return
    end
    self:BuildLustRoster()
    local unit = self.lustInspectUnit
    local name = UnitExists(unit) and PA_LustSafeUnitName(unit) or nil
    local rosterEntry = name and self.lustRosterByName and self.lustRosterByName[name] or nil
    if rosterEntry and rosterEntry.class == "HUNTER" and not rosterEntry.isSelf then
        local specID = GetInspectSpecialization and GetInspectSpecialization(unit) or 0
        specID = PA_Num(specID)
        self:GetLustSource(name, rosterEntry.class)
        self:RefreshHunterSpecState(name, specID, false)
    end
    self:ClearLustInspectState()
    self:ScheduleLustInspectResume()
end

function Timers:GetHunterSpecIDForEntry(entry)
    if not entry or entry.class ~= "HUNTER" then
        return 0
    end
    if entry.isSelf then
        local specIndex = GetSpecialization and GetSpecialization() or nil
        return PA_Num(specIndex and GetSpecializationInfo(specIndex) or 0)
    end
    local source = self.lustSources and self.lustSources[entry.name] or nil
    local specID = PA_Num(source and source.specID)
    if specID > 0 then
        return specID
    end
    return PA_Num(self.lustHunterSpecs and self.lustHunterSpecs[entry.name] or 0)
end

function Timers:GetPlayerAuraBySpellIDs(spellIDs, filter, lookup)
    for _, spellID in ipairs(type(spellIDs) == "table" and spellIDs or {}) do
        if C_UnitAuras and C_UnitAuras.GetPlayerAuraBySpellID then
            local ok, aura = pcall(C_UnitAuras.GetPlayerAuraBySpellID, spellID)
            if ok and type(aura) == "table" then
                return aura, spellID
            end
        end
    end
    if C_UnitAuras and C_UnitAuras.GetAuraDataByIndex then
        for i = 1, 255 do
            local ok, aura = pcall(C_UnitAuras.GetAuraDataByIndex, "player", i, filter)
            if not ok or aura == nil then
                break
            end
            local auraSpellID = PA_Num(aura.spellId or aura.spellID)
            if auraSpellID > 0 and lookup and lookup[auraSpellID] then
                return aura, auraSpellID
            end
        end
    end
    if UnitAura then
        for i = 1, 255 do
            local name, _, _, _, duration, expirationTime, _, _, _, auraSpellID = UnitAura("player", i, filter)
            if not name then
                break
            end
            auraSpellID = PA_Num(auraSpellID)
            if auraSpellID > 0 and lookup and lookup[auraSpellID] then
                return {
                    duration = PA_Num(duration),
                    expirationTime = PA_Num(expirationTime),
                    spellId = auraSpellID,
                }, auraSpellID
            end
        end
    end
    return nil, 0
end

function Timers:GetLocalLustAuraState()
    local activeAura = self:GetPlayerAuraBySpellIDs(PA_LUST.BUFFS, "HELPFUL", PA_LUST.SUPPORTED_SPELLS)
    local satedAura = self:GetPlayerAuraBySpellIDs(PA_LUST.EXHAUST_DEBUFFS, "HARMFUL", PA_LUST.EXHAUST_LOOKUP)
    local activeExpiration = PA_Num(activeAura and activeAura.expirationTime)
    local satedExpiration = PA_Num(satedAura and satedAura.expirationTime)
    local now = PA_Num(GetTime())
    local activeRemaining = activeExpiration > 0 and math.max(0, activeExpiration - now) or 0
    local satedRemaining = satedExpiration > 0 and math.max(0, satedExpiration - now) or 0
    return activeRemaining, satedRemaining
end

function Timers:RevalidateObservedLustCastOnRosterUpdate(activeRemaining, satedRemaining)
    local record = self.lustLastObservedCast
    if not record then
        return
    end

    local validRecord = self:GetValidObservedLustCast()
    if not validRecord then
        return
    end

    local isGrouped = false
    if type(IsInGroup) == "function" then
        local ok, grouped = pcall(IsInGroup)
        isGrouped = ok and grouped and true or false
    end
    if not isGrouped and not validRecord.isSelf then
        self:ClearLustObservedCast()
        return
    end

    local current = self.lustRosterByName or {}
    if current[validRecord.name] then
        return
    end

    if PA_Num(activeRemaining) > 0 or PA_Num(satedRemaining) > 0 then
        return
    end

    self:ClearLustObservedCast()
end

function Timers:GetFallbackActiveLustInfo(satedRemaining)
    return nil, 0
end

function Timers:ClearLustAudioState()
    self.lustWasSated = false
    self.lustSatedEndArmed = false
    self.lustSoundMonitoringActive = false
end

function Timers:SeedLustSatedEndAudioState()
    local _, satedRemaining = self:GetLocalLustAuraState()
    self.lustWasSated = satedRemaining > 0
    self.lustSatedEndArmed = true
    self.lustSoundMonitoringActive = true
end

function Timers:RefreshLustSoundArming()
    local db = self:GetDB()
    local monitoringEnabled = self:IsEnabled() and (not not db.lustEnabled) and (not not db.lustSatedEndSound) and (not Modules.timersTestMode)
    if monitoringEnabled then
        if not self.lustSoundMonitoringActive then
            self:SeedLustSatedEndAudioState()
        end
    else
        self:ClearLustAudioState()
    end
end

function Timers:RefreshLocalLustAuraState(skipAudio)
    local activeRemaining, satedRemaining = self:GetLocalLustAuraState()
    self:RefreshLustSoundArming()

    if not skipAudio and self.lustSoundMonitoringActive and self.lustSatedEndArmed and self.lustWasSated and satedRemaining <= 0 then
        local isConnected = UnitIsConnected and UnitIsConnected("player")
        local isDead = UnitIsDeadOrGhost and UnitIsDeadOrGhost("player")
        if isConnected and not isDead and PlaySoundFile then
            pcall(PlaySoundFile, PA_LUST.AUDIO_PATH, "Master")
        end
    end

    if self.lustSoundMonitoringActive then
        self.lustWasSated = satedRemaining > 0
    end

    return activeRemaining, satedRemaining
end

function Timers:UpdateLustSourceFromObservedCast(entry, spellID)
    if not entry or not entry.name then
        return
    end
    local cooldownSeconds = PA_LustResolveCooldownSeconds(spellID)
    if not cooldownSeconds or cooldownSeconds <= 0 then
        return
    end
    local source = self:GetLustSource(entry.name, entry.class)
    if not source then
        return
    end

    local now = PA_ServerNow()
    source.class = entry.class
    source.spellID = PA_Num(spellID)
    source.castAt = now
    source.readyAt = now + cooldownSeconds
    source.lastSeenAt = now
    if entry.class == "HUNTER" and PA_Num(spellID) == 466904 then
        source.specID = PA_LUST.SPEC_MARKSMANSHIP
        self.lustHunterSpecs[entry.name] = PA_LUST.SPEC_MARKSMANSHIP
    end
end

function Timers:GetLustSourceState(entry, now)
    if not entry or not entry.name or not entry.class then
        return false, false, nil, 0
    end

    local source = self:GetLustSource(entry.name, entry.class)
    local spellID = PA_Num(source and source.spellID)
    local readyAt = PA_Num(source and source.readyAt)

    if entry.class == "MAGE" or entry.class == "SHAMAN" or entry.class == "EVOKER" then
        if readyAt > now then
            return true, false, readyAt - now, spellID
        end
        if spellID <= 0 and entry.isSelf then
            spellID = PA_LustGetSelfDirectSpellID(entry.class)
        end
        return true, true, 0, spellID
    end

    if entry.class == "HUNTER" then
        local specID = self:GetHunterSpecIDForEntry(entry)
        if spellID > 0 then
            if readyAt > now then
                return true, false, readyAt - now, spellID
            end
            return true, true, 0, spellID
        end
        if specID == PA_LUST.SPEC_MARKSMANSHIP then
            return true, true, 0, 466904
        end
    end

    return false, false, nil, 0
end

function Timers:GetLustState()
    self.lustActiveDisplayInfo = nil
    local activeRemaining, satedRemaining = self:RefreshLocalLustAuraState()
    if activeRemaining > 0 then
        self.lustActiveDisplayInfo = select(1, self:GetValidObservedLustCast())
        return "active", activeRemaining, PA_LUST.ACTIVE_SECONDS
    end
    if satedRemaining > 0 then
        return "sated", satedRemaining, PA_LUST.SATED_SECONDS
    end

    self:BuildLustRoster()
    local now = PA_ServerNow()
    local anySupported = false
    local anyReady = false
    local nextRemaining = nil
    local nextDuration = 0

    for _, entry in ipairs(self.lustRosterList or {}) do
        local supported, ready, remaining, spellID = self:GetLustSourceState(entry, now)
        if supported then
            anySupported = true
        end
        if supported and ready then
            anyReady = true
        elseif supported and remaining and remaining > 0 then
            if not nextRemaining or remaining < nextRemaining then
                nextRemaining = remaining
                nextDuration = PA_LustResolveCooldownSeconds(spellID) or remaining
            end
        end
    end

    if anyReady then
        return "ready", 0, 0
    end
    if nextRemaining and nextRemaining > 0 then
        return "next", nextRemaining, nextDuration
    end
    if anySupported then
        return "ready", 0, 0
    end
    return "hidden", 0, 0
end

function Timers:GetLustEntryForObservedUnit(unit)
    if not unit then
        return nil
    end
    local ownerUnit = nil
    local petUnit = nil
    local isSelf = false

    if unit == "player" or unit == "pet" then
        ownerUnit = "player"
        petUnit = "pet"
        isSelf = true
    else
        local partyIndex = tostring(unit):match("^party(%d+)$") or tostring(unit):match("^partypet(%d+)$")
        if partyIndex then
            ownerUnit = "party" .. partyIndex
            petUnit = "partypet" .. partyIndex
        end
    end

    if not (ownerUnit and UnitExists(ownerUnit)) then
        return nil
    end

    local ownerName = PA_LustSafeUnitName(ownerUnit)
    local ownerClass = PA_LustSafeUnitClass(ownerUnit)
    if ownerName and ownerClass then
        return {
            name = ownerName,
            unit = ownerUnit,
            petUnit = petUnit,
            class = ownerClass,
            isSelf = isSelf,
        }
    end

    self:BuildLustRoster()
    return ownerName and self.lustRosterByName and self.lustRosterByName[ownerName] or nil
end

function Timers:RegisterLustSpellcastWatchers()
    if not self.lustSpellcastFrame then
        self.lustSpellcastFrame = CreateFrame("Frame")
    end

    self.lustSpellcastFrame:UnregisterAllEvents()
    self.lustSpellcastFrame:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED")
    self.lustSpellcastFrame:SetScript("OnEvent", function(_, event, unit, _, spellID)
        if event ~= "UNIT_SPELLCAST_SUCCEEDED" then
            return
        end
        local unitToken = PA_LustNormalizeObservedUnitToken(unit)
        if not unitToken then
            return
        end
        if not (unitToken == "player" or unitToken == "pet" or unitToken:match("^party%d+$") or unitToken:match("^partypet%d+$")) then
            return
        end

        PA_CpuDiagRecordUnitCallback(event)
        local perfStart, perfState = PA_PerfBegin("callback_class_unit_event")
        if self:HandleObservedLustCast(unitToken, spellID) then
            self:EvaluateVisibility()
        end
        PA_PerfEnd("callback_class_unit_event", perfStart, perfState)
    end)
end

function Timers:HandleObservedLustCast(unit, spellID)
    local resolvedSpellID = PA_Num(spellID)
    if resolvedSpellID <= 0 or not PA_LUST.SUPPORTED_SPELLS[resolvedSpellID] then
        return false
    end

    local recorded = self:RecordLustObservedSpellcast(unit, resolvedSpellID)
    if not recorded then
        return false
    end

    local ok, entry = pcall(self.GetLustEntryForObservedUnit, self, unit)
    if not ok then
        entry = nil
    end
    if not entry then
        return true
    end

    self:UpdateLustSourceFromObservedCast(entry, resolvedSpellID)
    self:RecordLustObservedCast(entry, resolvedSpellID)
    return true
end

function Timers:RefreshLustContextState()
    self:CleanupLustRosterState()
    self:QueueLustHunterInspect()
end

function Timers:FormatMMSS(remaining)
    remaining = math.max(0, tonumber(remaining) or 0)
    local mm = math.floor(remaining / 60)
    local ss = math.floor(remaining % 60)
    return string.format("%02d:%02d", mm, ss)
end

function Timers:ShouldKeepTickerActive(testing, brezRemaining, lustMode, lustRemaining)
    if testing or self.unlocked then
        return true
    end
    if PA_Num(brezRemaining) > 0 then
        return true
    end
    if PA_Num(lustRemaining) > 0 and (lustMode == "active" or lustMode == "sated" or lustMode == "next") then
        return true
    end
    return false
end

function Timers:Tick()
    PA_CpuDiagCount("timers_tick")
    local perfStart, perfState = PA_PerfBegin("timers_tick")
    local function finish(...)
        PA_PerfEnd("timers_tick", perfStart, perfState)
        return ...
    end

    if PA_IsUiSurfaceGateEnabled() then
        if self.frame then
            PA_CpuDiagApplyVisibility("timers", self.frame, false)
        end
        self:StopTicker()
        return finish(false)
    end

    local db = self:GetDB()
    if not self:IsEnabled() then
        if self.rezRow then self.rezRow:Hide() end
        if self.lustRow then self.lustRow:Hide() end
        if self.healerRow then self.healerRow:Hide() end
        PA_CpuDiagApplyVisibility("timers", self.frame, false)
        self:StopTicker()
        self:ClearLustAudioState()
        return finish(false)
    end
    local testing = Modules.timersTestMode
    local brezLiveEnabled = BrezTimer:IsMythicKeystoneActive()
    local brezRowShown = not not db.brezEnabled and (testing or self.unlocked or brezLiveEnabled)
    local lustRowShown = not not db.lustEnabled
    local healerRowShown = not not db.healerEnabled
    local lustRowVisible = false
    local brezRemainingForTicker = 0
    local lustModeForTicker = nil
    local lustRemainingForTicker = 0

    if not brezRowShown then self.rezRow:Hide() end
    if not lustRowShown then self.lustRow:Hide() end
    if not healerRowShown then
        self.healerRow:Hide()
        self.healerRow.baseTexCoord = { 0, 1, 0, 1 }
        self.healerUnit = nil
    end

    local shouldShow = self:ShouldShowLive() or testing or self.unlocked
    if not shouldShow then
        PA_CpuDiagApplyVisibility("timers", self.frame, false)
        return finish(false)
    end

    self:ApplyFonts()
    local iconWidth, iconHeight, spacing = self:GetIconDimensions()
    local db = self:GetDB()
    local iconOffsetX = db.iconOffsetX or 0
    local iconOffsetY = db.iconOffsetY or 0

    local rowPad = 2
    local y = -rowPad
    local maxW = 180
    local totalH = 0

    local showAny = false
    local sweepEnabled = not not db.cooldownSweepEnabled

    if self.rezRow and self.rezRow.cooldown and not sweepEnabled then
        self.rezRow.cooldown:Hide()
        self.rezRow._cdEndAt = nil
        self.rezRow._cdActive = false
        self.rezRow._cdMode = nil
    end
    if self.lustRow and self.lustRow.cooldown and not sweepEnabled then
        self.lustRow.cooldown:Hide()
        self.lustRow._cdEndAt = nil
        self.lustRow._cdActive = false
        self.lustRow._cdMode = nil
    end
    if self.healerRow and self.healerRow.cooldown then
        self.healerRow.cooldown:Hide()
        self.healerRow._cdActive = false
        self.healerRow._cdMode = nil
    end

    if brezRowShown then
        local charges = 1
        local remaining = 10
        if testing then
            if not self._testBrezEndAt or self._testBrezEndAt <= GetTime() then
                self._testBrezEndAt = GetTime() + 10
            end
            remaining = math.max(0, PA_Num(self._testBrezEndAt - GetTime()))
        else
            local state = BrezTimer:GetAuthoritativeState()
            charges = math.max(0, math.floor(PA_Num(state.charges)))
            remaining = PA_Num(state.remaining)
        end
        brezRemainingForTicker = PA_Num(remaining)
        self.rezLabel:SetText("Res: |cff33ff33" .. tostring(charges) .. "|r")
        self.rezLabel:SetTextColor(1, 1, 1, 1)
        if PA_Num(remaining) > 0 then
            self.rezCharges:SetText(self:FormatMMSS(remaining))
        else
            self.rezCharges:SetText("")
        end
        local rem = PA_Num(remaining)
        if self.rezRow and self.rezRow.cooldown then
            if sweepEnabled and rem > 0 then
                local endAt = GetTime() + rem
                if (self.rezRow._cdEndAt == nil) or math.abs(endAt - self.rezRow._cdEndAt) > 0.25 then
                    self.rezRow.cooldown:SetCooldown(GetTime(), rem)
                    self.rezRow.cooldown:Show()
                    self.rezRow._cdEndAt = endAt
                    self.rezRow._cdActive = true
                    self.rezRow._cdMode = "brez"
                end
            else
                self.rezRow.cooldown:Hide()
                self.rezRow._cdEndAt = nil
                self.rezRow._cdActive = false
                self.rezRow._cdMode = nil
            end
        end
        self.rezRow.baseTexCoord = { 0, 1, 0, 1 }
        self.rezRow:Show()
        showAny = true
    end

    if lustRowShown then
        local mode, remaining, sweepDuration = "next", 180, 300
        if not testing then
            mode, remaining, sweepDuration = self:GetLustState()
        end
        lustModeForTicker = mode
        lustRemainingForTicker = PA_Num(remaining)

        if mode == "hidden" then
            if self.lustRow.cooldown then
                self.lustRow.cooldown:Hide()
            end
            self.lustRow._cdEndAt = nil
            self.lustRow._cdActive = false
            self.lustRow._cdMode = nil
            self.lustRow:Hide()
        else
            if mode == "active" then
                local observed = self.lustActiveDisplayInfo or select(1, self:GetValidObservedLustCast())
                if observed and observed.shortName then
                    self.lustLabel:SetText(observed.shortName)
                    local color = RAID_CLASS_COLORS and RAID_CLASS_COLORS[observed.class]
                    if color then
                        self.lustLabel:SetTextColor(color.r, color.g, color.b, 1)
                    else
                        self.lustLabel:SetTextColor(1, 1, 1, 1)
                    end
                else
                    self.lustLabel:SetText("Active")
                    self.lustLabel:SetTextColor(1, 1, 1, 1)
                end
                self.lustValue:SetText(tostring(math.max(0, math.ceil(PA_Num(remaining)))))
                self.lustValue:SetTextColor(0.2, 1.0, 0.2, 1)
            elseif mode == "sated" then
                self.lustLabel:SetText("Sated")
                self.lustLabel:SetTextColor(1, 1, 1, 1)
                self.lustValue:SetText(self:FormatMMSS(remaining))
                self.lustValue:SetTextColor(1, 0.2, 0.2, 1)
            elseif mode == "next" then
                self.lustLabel:SetText("Next Lust")
                self.lustLabel:SetTextColor(1, 0.82, 0, 1)
                self.lustValue:SetText(self:FormatMMSS(remaining))
                self.lustValue:SetTextColor(1, 0.82, 0, 1)
            else
                self.lustLabel:SetText("")
                self.lustValue:SetText("Ready")
                self.lustValue:SetTextColor(0.2, 1.0, 0.2, 1)
            end

            local rem = PA_Num(remaining)
            local shouldShowSweep = sweepEnabled and self.lustRow.cooldown and rem > 0 and (mode == "active" or mode == "sated" or mode == "next")
            if shouldShowSweep then
                local dur = PA_Num(sweepDuration)
                if mode == "active" then
                    dur = PA_LUST.ACTIVE_SECONDS
                elseif mode == "sated" then
                    dur = PA_LUST.SATED_SECONDS
                end
                if dur <= 0 then
                    dur = rem
                end
                local endAt = GetTime() + rem
                if (not self.lustRow._cdActive) or (self.lustRow._cdMode ~= mode) or (self.lustRow._cdEndAt == nil) or math.abs(endAt - self.lustRow._cdEndAt) > 0.25 then
                    local start = GetTime() - math.max(0, dur - rem)
                    self.lustRow.cooldown:SetCooldown(start, dur)
                    self.lustRow.cooldown:Show()
                    self.lustRow._cdEndAt = endAt
                    self.lustRow._cdActive = true
                    self.lustRow._cdMode = mode
                end
            else
                if self.lustRow.cooldown then
                    self.lustRow.cooldown:Hide()
                end
                self.lustRow._cdEndAt = nil
                self.lustRow._cdActive = false
                self.lustRow._cdMode = nil
            end
            self.lustRow.baseTexCoord = { 0, 1, 0, 1 }
            self.lustRow:Show()
            lustRowVisible = true
            showAny = true
        end
    end

    if healerRowShown then
        if testing then
            local shamanColor = RAID_CLASS_COLORS and RAID_CLASS_COLORS.SHAMAN
            if shamanColor then
                self.healerName:SetTextColor(shamanColor.r, shamanColor.g, shamanColor.b, 1)
            else
                self.healerName:SetTextColor(0.0, 0.44, 0.87, 1)
            end
            self.healerName:SetText("Shiftus")
            self.healerMana:SetText("OK")
            self.healerMana:SetTextColor(0.2, 1.0, 0.2, 1)
            self.healerIcon:SetTexture("Interface\\GLUES\\CHARACTERCREATE\\UI-CHARACTERCREATE-CLASSES")
            if CLASS_ICON_TCOORDS and CLASS_ICON_TCOORDS.SHAMAN then
                local coords = CLASS_ICON_TCOORDS.SHAMAN
                self.healerIcon:SetTexCoord(coords[1], coords[2], coords[3], coords[4])
                self.healerRow.baseTexCoord = { coords[1], coords[2], coords[3], coords[4] }
            else
                self.healerRow.baseTexCoord = { 0, 1, 0, 1 }
            end
            self.healerRow:Show()
            showAny = true
            self.healerUnit = nil
        else
            local unit = self:FindHealerUnit()
            if unit and UnitExists(unit) then
            local name = UnitName(unit)
            local _, class = UnitClass(unit)
            local color = RAID_CLASS_COLORS and RAID_CLASS_COLORS[class]
            if color then
                self.healerName:SetTextColor(color.r, color.g, color.b, 1)
            else
                self.healerName:SetTextColor(1, 1, 1, 1)
            end
            self.healerName:SetText(name or "Healer")
            local statusText = "OK"
            local r, g, b = 0.2, 1.0, 0.2
            local isOffline = UnitIsConnected and not UnitIsConnected(unit)
            local isDead = UnitIsDeadOrGhost and UnitIsDeadOrGhost(unit)
            if isOffline then
                statusText = "OFFLINE"
                r, g, b = 1, 0.2, 0.2
            elseif isDead then
                statusText = "DEAD"
                r, g, b = 1, 0.2, 0.2
            else
                local inInst = false
                if IsInInstance then
                    inInst = select(1, IsInInstance())
                end
                if inInst and UnitIsVisible then
                    local okV, vis = pcall(UnitIsVisible, unit)
                    if okV and vis == false then
                        statusText = "OUTSIDE"
                        r, g, b = 1.0, 0.82, 0.0
                    end
                end
            end
            self.healerMana:SetText(statusText)
            self.healerMana:SetTextColor(r, g, b, 1)
            local coords = CLASS_ICON_TCOORDS and CLASS_ICON_TCOORDS[class]
            if coords then
                self.healerIcon:SetTexture("Interface\\GLUES\\CHARACTERCREATE\\UI-CHARACTERCREATE-CLASSES")
                self.healerIcon:SetTexCoord(coords[1], coords[2], coords[3], coords[4])
                self.healerRow.baseTexCoord = { coords[1], coords[2], coords[3], coords[4] }
            else
                self.healerRow.baseTexCoord = { 0, 1, 0, 1 }
            end
            self.healerRow:Show()
            showAny = true
            self.healerUnit = unit
            else
                self.healerUnit = nil
                local unlockedPreview = self.unlocked
                if unlockedPreview then
                    local shamanColor = RAID_CLASS_COLORS and RAID_CLASS_COLORS.SHAMAN
                    if shamanColor then
                        self.healerName:SetTextColor(shamanColor.r, shamanColor.g, shamanColor.b, 1)
                    else
                        self.healerName:SetTextColor(0.0, 0.44, 0.87, 1)
                    end
                    self.healerName:SetText("Shiftus")
                    self.healerMana:SetText("OK")
                    self.healerMana:SetTextColor(0.2, 1.0, 0.2, 1)

                    local shamanCoords = CLASS_ICON_TCOORDS and CLASS_ICON_TCOORDS.SHAMAN
                    if shamanCoords then
                        self.healerIcon:SetTexture("Interface\\GLUES\\CHARACTERCREATE\\UI-CHARACTERCREATE-CLASSES")
                        self.healerIcon:SetTexCoord(shamanCoords[1], shamanCoords[2], shamanCoords[3], shamanCoords[4])
                        self.healerRow.baseTexCoord = { shamanCoords[1], shamanCoords[2], shamanCoords[3], shamanCoords[4] }
                    else
                        self.healerIcon:SetTexture("Interface\\ICONS\\INV_Misc_QuestionMark")
                        self.healerIcon:SetTexCoord(0, 1, 0, 1)
                        self.healerRow.baseTexCoord = { 0, 1, 0, 1 }
                    end

                    self.healerRow:Show()
                    showAny = true
                else
                    self.healerRow:Hide()
                end
            end
        end
    end

    self:ApplyIconStyle()

    local function layoutRow(row, primary, secondary)
        row.icon:SetSize(iconWidth, iconHeight)
        row.primary:ClearAllPoints()
        row.primary:SetPoint("TOPLEFT", row.icon, "TOPRIGHT", spacing + iconOffsetX, iconOffsetY)
        row.secondary:ClearAllPoints()
        row.secondary:SetPoint("TOPLEFT", row.primary, "BOTTOMLEFT", 0, -2)

        row:SetPoint("TOPLEFT", self.frame, "TOPLEFT", 0, y)
        local rowH = math.max(iconHeight, (primary:GetStringHeight() or 14) + (secondary:GetStringHeight() or 14) + 2)
        local rowW = iconWidth + math.max(0, spacing + iconOffsetX) + math.max(primary:GetStringWidth() or 0, secondary:GetStringWidth() or 0)
        if rowW > maxW then maxW = rowW end
        row:SetSize(rowW, rowH)
        y = y - rowH - rowPad
        totalH = totalH + rowH + rowPad
    end

    if brezRowShown then layoutRow(self.rezRow, self.rezLabel, self.rezCharges) end
    if lustRowVisible then layoutRow(self.lustRow, self.lustLabel, self.lustValue) end
    if healerRowShown then layoutRow(self.healerRow, self.healerName, self.healerMana) end

    self.frame:SetSize(math.max(120, maxW + 4), math.max(20, totalH + 2))
    self:SetUnlocked(not self:GetDB().locked)
    PA_CpuDiagApplyVisibility("timers", self.frame, showAny or self.unlocked)
    return finish(self:ShouldKeepTickerActive(testing, brezRemainingForTicker, lustModeForTicker, lustRemainingForTicker))
end

function Timers:ApplyFonts()
    local path, size, flags = self:GetFont()
    local size1 = tonumber(size) or 12
    local size2 = size1 + 2
    self.rezLabel:SetFont(path, size1, flags)
    self.rezCharges:SetFont(path, size2, flags)
    self.lustLabel:SetFont(path, size1, flags)
    self.lustValue:SetFont(path, size2, flags)
    self.healerName:SetFont(path, size1, flags)
    self.healerMana:SetFont(path, size2, flags)

    local iconWidth, iconHeight = self:GetIconDimensions()
    self.rezRow.icon:SetSize(iconWidth, iconHeight)
    self.lustRow.icon:SetSize(iconWidth, iconHeight)
    self.healerRow.icon:SetSize(iconWidth, iconHeight)
    self:ApplyIconStyle()
end

function Timers:StartTicker()
    if self.ticker then return end
    self.ticker = C_Timer.NewTicker(0.25, function()
        if self:Tick() == false then
            self:StopTicker()
        end
    end)
end

function Timers:StopTicker()
    if self.ticker then self.ticker:Cancel() self.ticker = nil end
end

function Timers:EvaluateVisibility()
    PA_CpuDiagCount("timers_evaluate_visibility")
    local perfStart, perfState = PA_PerfBegin("timers_evaluate_visibility")
    if PA_IsUiSurfaceGateEnabled() then
        if self.frame then
            PA_CpuDiagApplyVisibility("timers", self.frame, false)
        end
        self:StopTicker()
        PA_PerfEnd("timers_evaluate_visibility", perfStart, perfState)
        return
    end
    if not self.frame then
        PA_PerfEnd("timers_evaluate_visibility", perfStart, perfState)
        return
    end
    if not self:IsEnabled() then
        PA_CpuDiagApplyVisibility("timers", self.frame, false)
        self:StopTicker()
        self:ClearLustAudioState()
        PA_PerfEnd("timers_evaluate_visibility", perfStart, perfState)
        return
    end
    local shouldShow = self:ShouldShowLive() or Modules.timersTestMode or self.unlocked
    if shouldShow then
        PA_CpuDiagApplyVisibility("timers", self.frame, true)
        self:ApplyFonts()
        local keepTicker = self:Tick()
        if keepTicker then
            self:StartTicker()
        else
            self:StopTicker()
        end
    else
        PA_CpuDiagApplyVisibility("timers", self.frame, false)
        self:StopTicker()
    end
    PA_PerfEnd("timers_evaluate_visibility", perfStart, perfState)
end

function Timers:SetUnlocked(unlocked)
    local wasUnlocked = self.unlocked and true or false
    self.unlocked = not not unlocked
    local db = self:GetDB()
    db.locked = not self.unlocked
    local effectiveUnlocked = self.unlocked and self:IsEnabled()
    local wasEffectiveUnlocked = wasUnlocked and self:IsEnabled()
    if self.dragHandle then
        self.dragHandle:SetShown(effectiveUnlocked)
        self.dragHandle:EnableMouse(effectiveUnlocked)
    end
    if wasEffectiveUnlocked ~= effectiveUnlocked and PortalAuthority and PortalAuthority.UpdateMoveHintTickerState then
        PortalAuthority:UpdateMoveHintTickerState()
    end
end

function Timers:ApplySettings()
    if not self.frame then return end
    self:RefreshLustSoundArming()
    self:ApplyPosition()
    self:ApplyFonts()
    self:EvaluateVisibility()
    if Modules.timersTestMode and PA_CpuDiagIsFrameConsideredShown("timers", self.frame) then
        self:Tick()
    end
end

function Timers:Initialize()
    if PA_IsUiSurfaceGateEnabled() then
        return
    end
    if self.frame then return end
    self:EnsureDB()
    self:EnsureLustRuntime()
    local frame = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    frame:SetSize(180, 70)
    frame:SetMovable(true)
    frame:SetClampedToScreen(false)
    frame:SetUserPlaced(false)
    frame:EnableMouse(false)

    local function buildRow(texture)
        local row = CreateFrame("Frame", nil, frame)
        local icon = row:CreateTexture(nil, "ARTWORK")
        icon:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0)
        icon:SetSize(18, 18)
        icon:SetTexture(texture)
        local primary = row:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
        primary:SetJustifyH("LEFT")
        local secondary = row:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
        secondary:SetJustifyH("LEFT")
        primary:SetPoint("TOPLEFT", icon, "TOPRIGHT", 8, 0)
        secondary:SetPoint("TOPLEFT", primary, "BOTTOMLEFT", 0, -2)
        local cd = CreateFrame("Cooldown", nil, row, "CooldownFrameTemplate")
        cd:SetAllPoints(icon)
        if cd.SetDrawEdge then cd:SetDrawEdge(false) end
        if cd.SetHideCountdownNumbers then cd:SetHideCountdownNumbers(true) end
        cd:Hide()
        row.icon, row.primary, row.secondary = icon, primary, secondary
        row.cooldown = cd
        row._cdEndAt = nil
        row._cdActive = false
        row._cdMode = nil
        return row
    end

    self.rezRow = buildRow(136080)
    self.rezLabel = self.rezRow.primary
    self.rezCharges = self.rezRow.secondary
    self.rezLabel:SetTextColor(1, 1, 1, 1)
    self.rezLabel:SetShadowOffset(1, -1)
    self.rezCharges:SetTextColor(1, 1, 1, 1)

    self.lustRow = buildRow(136012)
    self.lustLabel = self.lustRow.primary
    self.lustValue = self.lustRow.secondary
    self.lustLabel:SetTextColor(1, 1, 1, 1)
    self.lustLabel:SetShadowOffset(1, -1)
    self:UpdateLustIcon()

    self.healerRow = buildRow(136243)
    self.healerIcon = self.healerRow.icon
    self.healerName = self.healerRow.primary
    self.healerMana = self.healerRow.secondary

    local handle = CreateFrame("Button", nil, frame, "BackdropTemplate")
    handle:SetPoint("BOTTOMLEFT", frame, "TOPLEFT", 0, 3)
    handle:SetPoint("BOTTOMRIGHT", frame, "TOPRIGHT", 0, 3)
    handle:SetHeight(16)
    handle:SetBackdrop({ bgFile = "Interface/Buttons/WHITE8X8", edgeFile = "Interface/Tooltips/UI-Tooltip-Border", edgeSize = 10, insets = { left = 2, right = 2, top = 2, bottom = 2 } })
    handle:SetBackdropColor(0.0, 1.0, 0.0, 0.85)
    handle:SetBackdropBorderColor(0.0, 0.25, 0.0, 1.0)
    local glyph = handle:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    glyph:SetPoint("CENTER", handle, "CENTER", 0, 0)
    glyph:SetText("GRAB")
    glyph:SetTextColor(0, 0, 0, 1)
    handle:EnableMouse(false)
    handle:RegisterForDrag("LeftButton")
    handle:SetScript("OnDragStart", function()
        if not self.unlocked or InCombatLockdown() then return end
        frame:StartMoving()
        if not self._dragTicker then
            self._dragTicker = C_Timer.NewTicker(0.1, function() self:NotifyDragPosition() end)
        end
    end)
    handle:SetScript("OnDragStop", function()
        frame:StopMovingOrSizing()
        if self._dragTicker then self._dragTicker:Cancel() self._dragTicker = nil end
        self:PersistPosition()
    end)

    self.frame = frame
    self.dragHandle = handle
    self:SetUnlocked(not self:GetDB().locked)
    self:RegisterLustSpellcastWatchers()
    self:RefreshLustContextState()
    self:RefreshLocalLustAuraState(true)
    self:ApplyPosition()
    self:ApplySettings()

end

function Timers:OnEvent(event, arg1, arg2, arg3, arg4)
    if event == "CHALLENGE_MODE_START" then
        self.lustSources = {}
        self:ClearLustObservedCast()
        self:ClearLustObservedSpellcast()
        self:RefreshLustContextState()
        self:EvaluateVisibility()
        return
    end

    if event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" then
        self:ClearLustObservedCast()
        self:ClearLustObservedSpellcast()
        self:RefreshLustContextState()
        self:RefreshLocalLustAuraState(true)
        self:EvaluateVisibility()
        return
    end

    if event == "GROUP_ROSTER_UPDATE" then
        self:RefreshLustContextState()
        local activeRemaining, satedRemaining = self:RefreshLocalLustAuraState(true)
        self:RevalidateObservedLustCastOnRosterUpdate(activeRemaining, satedRemaining)
        self:EvaluateVisibility()
        return
    end

    if event == "PLAYER_SPECIALIZATION_CHANGED" then
        local unit = arg1
        if unit == "player" then
            local playerName = PA_LustSafeUnitName("player")
            local playerClass = PA_LustSafeUnitClass("player")
            if playerName and playerClass then
                self:GetLustSource(playerName, playerClass)
                local specID = (playerClass == "HUNTER") and self:GetHunterSpecIDForEntry({
                    name = playerName,
                    class = playerClass,
                    isSelf = true,
                }) or 0
                self:RefreshHunterSpecState(playerName, specID, false)
            end
        elseif unit and tostring(unit):match("^party%d+$") then
            local partyName = PA_LustSafeUnitName(unit)
            if partyName then
                self:RefreshHunterSpecState(partyName, 0, true)
                self:QueueLustHunterInspect(unit)
            end
        end
        self:EvaluateVisibility()
        return
    end

    if event == "INSPECT_READY" then
        self:HandleLustInspectReady(arg1)
        self:EvaluateVisibility()
        return
    end

    if event == "PLAYER_REGEN_ENABLED" then
        self:CleanupLustRosterState()
        self:ProcessLustInspectQueue()
        self:EvaluateVisibility()
        return
    end

    if event == "UNIT_AURA" and arg1 == "player" then
        self:RefreshLocalLustAuraState()
        self:EvaluateVisibility()
        return
    end

    if event == "PLAYER_DEAD" or event == "PLAYER_ALIVE" or event == "PLAYER_UNGHOST" then
        self:RefreshLocalLustAuraState(true)
        self:EvaluateVisibility()
        return
    end

    if event == "UNIT_PET" and (arg1 == "player" or tostring(arg1 or ""):match("^party%d+$")) then
        self:HandleLustHunterPetChanged(arg1)
        self:RefreshLustContextState()
        self:EvaluateVisibility()
        return
    end

    if event == "UNIT_POWER_UPDATE" and self.healerUnit and arg1 ~= self.healerUnit then return end
    self:EvaluateVisibility()
end

Modules:Register(Timers)

end
