--[[
    Horizon Suite - Presence - Core
    Cinematic zone text and notification display. Frame, layers, animation engine,
    and public QueueOrPlay API. Ported from ModernZoneText.
    Step-by-step flow notes: notes/PresenceCore.md

    Design notes:
    - Colour is resolved at show time only (resolveColors, getDiscoveryColor); OnUpdate
      touches alpha and layout only, never colour or text.
    - Presence uses fixed cinematic timings (ENTRANCE_DUR 0.7s, EXIT_DUR 0.8s) and
      larger type sizes by design.
    - QueueOrPlay(typeName, title, subtitle, opts): title = heading, subtitle = second
      line; opts.questID is for colour/icon only, never displayed.
]]
local addon = _G.HorizonSuite
if not addon then return end



addon.Presence = addon.Presence or {}

-- ============================================================================
-- SCENARIO HELPERS (standalone; no Focus dependency)
-- ============================================================================

--- True when the player is in an active scenario. Uses addon.IsWorldScenario when Focus loaded, else C_Scenario.GetInfo.
function addon.Presence.IsScenarioActive()
    if addon.IsWorldScenario and addon.IsWorldScenario() then return true end
    local ok, name, currentStage = pcall(C_Scenario.GetInfo)
    return ok and ((name and name ~= "") or (currentStage and currentStage > 0))
end

--- Get display info for Presence scenario toasts. Title, subtitle, category. No Focus dependency.
--- @return title string|nil, subtitle string|nil, category string|nil
function addon.Presence.GetScenarioDisplayInfo()
    if not addon.Presence.IsScenarioActive() then return nil, nil, nil end
    local isDelve = addon.IsDelveActive and addon.IsDelveActive()
    local inPartyDungeon = addon.IsInPartyDungeon and addon.IsInPartyDungeon()
    local category = isDelve and "DELVES" or (inPartyDungeon and "DUNGEON") or "SCENARIO"

    local scenarioName
    local ok, name = pcall(C_Scenario.GetInfo)
    if ok and name and name ~= "" then scenarioName = name end

    local stageName
    local sOk, sName = pcall(C_Scenario.GetStepInfo)
    if sOk and sName and sName ~= "" then stageName = sName end

    local title = scenarioName
    if inPartyDungeon then
        local instOk, instanceName = pcall(GetInstanceInfo)
        title = (instOk and instanceName) or "Dungeon"
    elseif not title or title == "" then
        title = "Scenario"
    end

    return title, stageName or "", category
end

--- Strip WoW markup (textures, colors) from a string for display.
--- @param s string|nil
--- @return string
function addon.Presence.StripMarkup(s)
    if not s or s == "" then return s or "" end
    s = s:gsub("|T.-|t", "")
    s = s:gsub("|c%x%x%x%x%x%x%x%x", "")
    s = s:gsub("|r", "")
    return strtrim(s)
end

-- ============================================================================
-- CONFIGURATION
-- ============================================================================

local MAIN_SIZE    = 48
local SUB_SIZE     = 24
local FRAME_WIDTH  = 800
local FRAME_HEIGHT = 250
local FRAME_Y_DEF  = -180
local DIVIDER_W    = 400
local DIVIDER_H    = 2
local MAX_QUEUE    = 8

local ENTRANCE_DUR_DEF  = 0.7
local EXIT_DUR_DEF      = 0.8
local CROSSFADE_DUR = 0.4
local ELEMENT_DUR   = 0.4
local SUBTITLE_TRANSITION_DUR = 0.12

local DISCOVERY_SIZE  = 16
local QUEST_ICON_SIZE = 24  -- quest-type icon in toasts; larger than Focus (16) to match heading scale
local DELAY_TITLE     = 0.0
local DELAY_DIVIDER   = 0.15
local DELAY_SUBTITLE  = 0.30
local DELAY_DISCOVERY = 0.45

local PREVIEW_FRAME_WIDTH        = 640
local PREVIEW_FRAME_HEIGHT       = 260
local PREVIEW_SCALE              = 0.55
local PREVIEW_POPOUT_WIDTH       = 640
local PREVIEW_POPOUT_HEIGHT      = 360
local PREVIEW_TITLE_ABOVE_DIVIDER  = 18
local PREVIEW_DIVIDER_BELOW_CENTER = 10
local PREVIEW_TITLE_ENTER_DELTA    = 0
local PREVIEW_TITLE_EXIT_DELTA     = 0
local PREVIEW_SUB_OFFSET_DELTA     = 0
local PREVIEW_PREP_DELAY           = 1.0  -- seconds to clear static content before entrance

local TYPES = {
    LEVEL_UP       = { pri = 4, category = "COMPLETE",   subCategory = "DEFAULT", sz = 48, dur = 5.0 },
    BOSS_EMOTE     = { pri = 4, specialColor = true,     subCategory = "DEFAULT", sz = 48, dur = 5.0 },
    ACHIEVEMENT    = { pri = 3, category = "ACHIEVEMENT", subCategory = "DEFAULT", sz = 48, dur = 4.5 },
    QUEST_COMPLETE = { pri = 2, category = "DEFAULT",   subCategory = "DEFAULT", sz = 48, dur = 4.0 },
    WORLD_QUEST    = { pri = 2, category = "WORLD",     subCategory = "DEFAULT", sz = 48, dur = 4.0 },
    ZONE_CHANGE    = { pri = 2, category = "DEFAULT",   subCategory = "CAMPAIGN", sz = 48, dur = 4.0 },
    QUEST_ACCEPT       = { pri = 1, category = "DEFAULT",   subCategory = "DEFAULT", sz = 36, dur = 3.0 },
    WORLD_QUEST_ACCEPT = { pri = 2, category = "WORLD",     subCategory = "DEFAULT", sz = 36, dur = 3.0 },
    QUEST_UPDATE       = { pri = 1, category = "DEFAULT",   subCategory = "DEFAULT", sz = 28, dur = 2.5, liveUpdate = true, replaceInQueue = true, subGap = 12 },
    SUBZONE_CHANGE     = { pri = 1, category = "DEFAULT",   subCategory = "CAMPAIGN", sz = 36, dur = 3.0 },
    SCENARIO_START     = { pri = 2, category = "SCENARIO", subCategory = "DEFAULT", sz = 36, dur = 3.5 },
    SCENARIO_UPDATE     = { pri = 1, category = "SCENARIO", subCategory = "DEFAULT", sz = 36, dur = 2.5, liveUpdate = true, replaceInQueue = true },
    SCENARIO_COMPLETE   = { pri = 2, category = "SCENARIO", subCategory = "DEFAULT", sz = 48, dur = 4.0 },
    ACHIEVEMENT_PROGRESS = { pri = 1, category = "ACHIEVEMENT", subCategory = "DEFAULT", sz = 28, dur = 2.5, liveUpdate = true, replaceInQueue = true, subGap = 12 },
    RARE_DEFEATED      = { pri = 2, category = "DEFAULT",  subCategory = "DEFAULT", sz = 36, dur = 3.5 },
}

-- Type name -> { key, fallback, default } for IsTypeEnabledForType.
-- Matches OptionsData presence toggles; used by Blizzard "any toast enabled" and domain handlers.
local TYPE_OPTIONS = {
    LEVEL_UP           = { key = "presenceLevelUp",           fallback = nil, default = true },
    BOSS_EMOTE         = { key = "presenceBossEmote",         fallback = nil, default = true },
    ACHIEVEMENT        = { key = "presenceAchievement",       fallback = nil, default = true },
    QUEST_COMPLETE     = { key = "presenceQuestComplete",     fallback = "presenceQuestEvents", default = true },
    WORLD_QUEST        = { key = "presenceWorldQuest",        fallback = "presenceQuestEvents", default = true },
    ZONE_CHANGE        = { key = "presenceZoneChange",         fallback = nil, default = true },
    QUEST_ACCEPT       = { key = "presenceQuestAccept",        fallback = "presenceQuestEvents", default = true },
    WORLD_QUEST_ACCEPT = { key = "presenceWorldQuestAccept", fallback = "presenceQuestEvents", default = true },
    QUEST_UPDATE       = { key = "presenceQuestUpdate",       fallback = "presenceQuestEvents", default = true },
    SUBZONE_CHANGE     = { key = "presenceSubzoneChange",     fallback = "presenceZoneChange",  default = true },
    SCENARIO_START     = { key = "presenceScenarioStart",    fallback = "showScenarioEvents",  default = true },
    SCENARIO_UPDATE    = { key = "presenceScenarioUpdate",   fallback = "showScenarioEvents",  default = true },
    SCENARIO_COMPLETE  = { key = "presenceScenarioComplete", fallback = "showScenarioEvents",  default = true },
    ACHIEVEMENT_PROGRESS = { key = "presenceAchievementProgress", fallback = nil, default = false },
    RARE_DEFEATED      = { key = "presenceRareDefeated",      fallback = nil, default = true },
}

-- Order and L-key mapping for preview dropdown. Used by options.
local PREVIEW_TYPE_ORDER = {
    "ZONE_CHANGE", "SUBZONE_CHANGE", "QUEST_ACCEPT", "WORLD_QUEST_ACCEPT", "QUEST_UPDATE",
    "QUEST_COMPLETE", "WORLD_QUEST", "SCENARIO_START", "SCENARIO_UPDATE", "SCENARIO_COMPLETE",
    "ACHIEVEMENT", "ACHIEVEMENT_PROGRESS", "BOSS_EMOTE", "LEVEL_UP", "RARE_DEFEATED",
}
local PREVIEW_TYPE_LABELS = {
    ZONE_CHANGE = "Zone entry",
    SUBZONE_CHANGE = "Subzone changes",
    QUEST_ACCEPT = "Quest accepted",
    WORLD_QUEST_ACCEPT = "World quest accepted",
    QUEST_UPDATE = "Quest progress",
    QUEST_COMPLETE = "Quest complete",
    WORLD_QUEST = "World quest complete",
    SCENARIO_START = "Scenario start",
    SCENARIO_UPDATE = "Scenario progress",
    SCENARIO_COMPLETE = "Scenario complete",
    ACHIEVEMENT = "Achievement earned",
    ACHIEVEMENT_PROGRESS = "Achievement progress",
    BOSS_EMOTE = "Boss emotes",
    LEVEL_UP = "Level up",
    RARE_DEFEATED = "Rare defeated",
}

local debounceTimers = {}

--- Check if a Presence type is enabled, with optional fallback to a grouped option.
--- @param key string DB key for the per-type toggle (e.g. presenceQuestAccept)
--- @param fallbackKey string|nil DB key for fallback when key is nil (e.g. presenceQuestEvents)
--- @param fallbackDefault boolean Default when fallbackKey is nil or not used
--- @return boolean
local function IsTypeEnabled(key, fallbackKey, fallbackDefault)
    if not addon.GetDB then return fallbackDefault end
    local v = addon.GetDB(key, nil)
    if v ~= nil then return v end
    return (fallbackKey and addon.GetDB(fallbackKey, fallbackDefault)) or fallbackDefault
end

--- Check if a Presence type (e.g. QUEST_ACCEPT, SCENARIO_UPDATE) is enabled via TYPE_OPTIONS.
--- @param typeName string One of TYPES keys (LEVEL_UP, QUEST_ACCEPT, etc.)
--- @return boolean
local function IsTypeEnabledForType(typeName)
    local opts = typeName and TYPE_OPTIONS[typeName]
    if not opts then return false end
    return IsTypeEnabled(opts.key, opts.fallback, opts.default)
end

--- Cancel existing timer for key, schedule callback after delay. Debounce helper.
--- @param key string Unique key (e.g. "quest:123", "scenario", "zone")
--- @param delay number Seconds before callback runs
--- @param callback function Called when timer fires
--- @return nil
local function RequestDebounced(key, delay, callback)
    if debounceTimers[key] then
        debounceTimers[key]:Cancel()
        debounceTimers[key] = nil
    end
    if not C_Timer or not C_Timer.After then return end
    debounceTimers[key] = C_Timer.After(delay, function()
        debounceTimers[key] = nil
        if callback then callback() end
    end)
end

--- Cancel a pending debounced callback for the given key.
--- @param key string Unique key passed to RequestDebounced
--- @return nil
local function CancelDebounced(key)
    if debounceTimers[key] then
        debounceTimers[key]:Cancel()
        debounceTimers[key] = nil
    end
end

--- Build display string from normalized objective.
--- Accepts { text?, finished?, numFulfilled?, numRequired?, quantityString?, percent? }.
--- For isWeightedProgress objectives, percent is the displayed value (0-100); quantityString is not used.
--- @param o table Normalized objective
--- @return string|nil
local function FormatObjectiveForDisplay(o)
    if not o then return nil end
    if o.percent ~= nil and type(o.percent) == "number" then
        if o.text and o.text ~= "" and o.text ~= "0" then
            return ("%s (%d%%)"):format(o.text, math.min(100, math.max(0, math.floor(o.percent))))
        end
        return ("%d%%"):format(math.min(100, math.max(0, math.floor(o.percent))))
    end
    if o.quantityString and o.quantityString ~= "" and o.quantityString ~= "0"
       and not (o.text and o.text ~= "" and o.quantityString:match("^%d+$")) then
        return o.quantityString
    end
    if o.text and o.text ~= "" and o.text ~= "0" then
        if o.numFulfilled ~= nil and o.numRequired ~= nil and o.numRequired > 0 then
            if o.numRequired == 1 then
                return o.text
            end
            local pattern = ("%d/%d"):format(o.numFulfilled, o.numRequired)
            if o.text:find(pattern, 1, true) then
                return o.text
            end
            return ("%s (%d/%d)"):format(o.text, o.numFulfilled, o.numRequired)
        end
        return o.text
    end
    if o.numFulfilled ~= nil and o.numRequired ~= nil and o.numRequired > 0 then
        if o.numRequired == 1 then
            return nil
        end
        return ("%d/%d"):format(o.numFulfilled, o.numRequired)
    end
    return nil
end

--- True if any event-toast type (achievement, quest, scenario) is enabled. Used by Blizzard suppression.
--- @return boolean
local function IsAnyToastEnabled()
    local toastTypes = { "ACHIEVEMENT", "ACHIEVEMENT_PROGRESS", "QUEST_ACCEPT", "WORLD_QUEST_ACCEPT", "QUEST_COMPLETE", "WORLD_QUEST", "QUEST_UPDATE", "SCENARIO_START", "SCENARIO_UPDATE", "SCENARIO_COMPLETE" }
    for _, t in ipairs(toastTypes) do
        if IsTypeEnabledForType(t) then return true end
    end
    return false
end

--- True when Presence should suppress non-essential notifications (M+ zone, or instance type).
--- @return boolean
local function ShouldSuppressType()
    if addon.GetDB and addon.GetDB("presenceSuppressZoneInMplus", true) and addon.IsInMythicDungeon and addon.IsInMythicDungeon() then
        return true
    end
    if not addon.GetDB then return false end
    local inType = select(2, GetInstanceInfo())
    if inType == "party" and addon.GetDB("presenceSuppressInDungeon", false) then return true end
    if inType == "raid"  and addon.GetDB("presenceSuppressInRaid", false)    then return true end
    if inType == "arena" and addon.GetDB("presenceSuppressInPvP", false)     then return true end
    if inType == "pvp"   and addon.GetDB("presenceSuppressInBattleground", false) then return true end
    return false
end

local function getFrameY()
    local v = addon.GetDB and tonumber(addon.GetDB("presenceFrameY", FRAME_Y_DEF)) or FRAME_Y_DEF
    return math.max(-300, math.min(0, v))
end

local function getFrameScale()
    local v = addon.GetDB and tonumber(addon.GetDB("presenceFrameScale", 1)) or 1
    local base = math.max(0.5, math.min(2, v))
    local moduleScale = (addon.GetModuleScale and addon.GetModuleScale("presence")) or 1
    return base * moduleScale
end

local function getEntranceDur()
    if addon.GetDB and not addon.GetDB("presenceAnimations", true) then return 0 end
    local v = addon.GetDB and tonumber(addon.GetDB("presenceEntranceDur", ENTRANCE_DUR_DEF)) or ENTRANCE_DUR_DEF
    return math.max(0.2, math.min(1.5, v))
end

local function getExitDur()
    if addon.GetDB and not addon.GetDB("presenceAnimations", true) then return 0 end
    local v = addon.GetDB and tonumber(addon.GetDB("presenceExitDur", EXIT_DUR_DEF)) or EXIT_DUR_DEF
    return math.max(0.2, math.min(1.5, v))
end

local function getHoldScale()
    local v = addon.GetDB and tonumber(addon.GetDB("presenceHoldScale", 1)) or 1
    return math.max(0.5, math.min(2, v))
end

local PRESENCE_FONT_USE_GLOBAL = "__global__"
local defaultFontPath = (addon.GetDefaultFontPath and addon.GetDefaultFontPath()) or "Fonts\\FRIZQT__.TTF"

-- Apply a font path with hard fallback so FontStrings never end up without a font.
local function SetSafeFont(fs, path, size, flags)
    if not fs then return false end
    if fs:SetFont(path, size, flags) then return true end
    if path ~= defaultFontPath and fs:SetFont(defaultFontPath, size, flags) then return true end
    return fs:SetFont("Fonts\\FRIZQT__.TTF", size, flags)
end

local function getPresenceFontPath()
    local global = addon.GetActiveGlobalFont and addon.GetActiveGlobalFont()
    if global then return global end
    local raw = addon.GetDB and addon.GetDB("fontPath", defaultFontPath) or defaultFontPath
    return (addon.ResolveFontPath and addon.ResolveFontPath(raw)) or raw
end

local function getPresenceTitleFontPath()
    local global = addon.GetActiveGlobalFont and addon.GetActiveGlobalFont()
    if global then return global end
    local raw = addon.GetDB and addon.GetDB("presenceTitleFontPath", PRESENCE_FONT_USE_GLOBAL) or PRESENCE_FONT_USE_GLOBAL
    if raw == PRESENCE_FONT_USE_GLOBAL or not raw or raw == "" then return getPresenceFontPath() end
    return (addon.ResolveFontPath and addon.ResolveFontPath(raw)) or raw
end

local function getPresenceSubtitleFontPath()
    local global = addon.GetActiveGlobalFont and addon.GetActiveGlobalFont()
    if global then return global end
    local raw = addon.GetDB and addon.GetDB("presenceSubtitleFontPath", PRESENCE_FONT_USE_GLOBAL) or PRESENCE_FONT_USE_GLOBAL
    if raw == PRESENCE_FONT_USE_GLOBAL or not raw or raw == "" then return getPresenceFontPath() end
    return (addon.ResolveFontPath and addon.ResolveFontPath(raw)) or raw
end

local function getPresenceTitleFontOutline()
    local raw = addon.GetDB and addon.GetDB("presenceTitleFontOutline", "OUTLINE")
    if raw == nil then return "OUTLINE" end
    return raw
end

local function getPresenceSubtitleFontOutline()
    local raw = addon.GetDB and addon.GetDB("presenceSubtitleFontOutline", "OUTLINE")
    if raw == nil then return "OUTLINE" end
    return raw
end

-- Per-FontString hook that re-asserts our desired font path whenever a
-- font-replacement addon (e.g. Platynator) overrides SetFont or SetFontObject.
-- Size is nil so PlayCinematic's variant-sized SetSafeFont calls go through unchanged;
-- the lock only protects the font path. Mirrors LockDirectFont in PresenceTalkingHead.lua.
local function LockDirectFont(fontString, getFont)
    local busyObj  = false
    local busyFont = false

    hooksecurefunc(fontString, "SetFontObject", function(self, obj)
        if busyObj or not obj then return end
        local path, size, flags = getFont()
        if not path then return end
        local _, curSize, curFlags = self:GetFont()
        busyObj = true
        self:SetFontObject(nil)
        self:SetFont(path, size or curSize or 12, flags or curFlags or "OUTLINE")
        busyObj = false
    end)

    hooksecurefunc(fontString, "SetFont", function(self, path, size, flags)
        if busyFont then return end
        local targetPath, targetSize, targetFlags = getFont()
        if not targetPath or path == targetPath then return end
        busyFont = true
        self:SetFont(targetPath, targetSize or size, targetFlags or flags or "OUTLINE")
        busyFont = false
    end)
end

local function GetPresenceTitleFont()
    return getPresenceTitleFontPath(), nil, getPresenceTitleFontOutline()
end

local function GetPresenceSubFont()
    return getPresenceSubtitleFontPath(), nil, getPresenceSubtitleFontOutline()
end

-- Variant-based sizes: large (sz 48), medium (sz 36), small (sz 28). Each has primary + secondary.
local VARIANT_DEFAULTS = {
    large  = { primary = 48, secondary = 24 },
    medium = { primary = 36, secondary = 22 },
    small  = { primary = 28, secondary = 20 },
}

local VARIANT_KEYS = {
    large  = { "presencePrimaryLargeSz",  "presenceSecondaryLargeSz"  },
    medium = { "presencePrimaryMediumSz", "presenceSecondaryMediumSz" },
    small  = { "presencePrimarySmallSz",  "presenceSecondarySmallSz"  },
}

local function getVariant(cfg)
    if cfg.sz >= 44 then return "large" end
    if cfg.sz >= 32 then return "medium" end
    return "small"
end

local function getPrimarySz(variant)
    local def = VARIANT_DEFAULTS[variant]
    local key = VARIANT_KEYS[variant][1]
    local v = addon.GetDB and tonumber(addon.GetDB(key, def.primary))
    return math.max(12, math.min(72, v or def.primary))
end

local function getSecondarySz(variant)
    local def = VARIANT_DEFAULTS[variant]
    local key = VARIANT_KEYS[variant][2]
    local v = addon.GetDB and tonumber(addon.GetDB(key, def.secondary))
    return math.max(12, math.min(40, v or def.secondary))
end

local function getCategoryColor(cat, default)
    local c = (addon.GetQuestColor and addon.GetQuestColor(cat)) or (addon.QUEST_COLORS and addon.QUEST_COLORS[cat]) or (addon.QUEST_COLORS and addon.QUEST_COLORS.DEFAULT) or default
    return c
end

local function getDiscoveryColor()
    return (addon.GetPresenceDiscoveryColor and addon.GetPresenceDiscoveryColor()) or addon.PRESENCE_DISCOVERY_COLOR or getCategoryColor("COMPLETE", { 0.4, 1, 0.5 })
end

--- Returns a color for the current zone PvP type (friendly/hostile/contested/sanctuary).
--- Uses user-configured colors if set, otherwise sane defaults.
--- @return table|nil {r,g,b} or nil if zone type is unknown
local function GetZoneTypeColor()
    local pvpType = (C_PvP and C_PvP.GetZonePVPInfo) and C_PvP.GetZonePVPInfo() or (GetZonePVPInfo and GetZonePVPInfo()) or nil
    if not pvpType or pvpType == "" then return nil end
    local colorKey
    if pvpType == "friendly" then
        colorKey = "presenceZoneColorFriendly"
    elseif pvpType == "hostile" then
        colorKey = "presenceZoneColorHostile"
    elseif pvpType == "contested" then
        colorKey = "presenceZoneColorContested"
    elseif pvpType == "sanctuary" then
        colorKey = "presenceZoneColorSanctuary"
    else
        return nil
    end
    local c = addon.GetDB and addon.GetDB(colorKey, nil)
    if c and type(c) == "table" and c[1] and c[2] and c[3] then return c end
    -- Sane defaults
    local defaults = {
        presenceZoneColorFriendly  = { 0.1, 1.0, 0.1 },  -- green
        presenceZoneColorHostile   = { 1.0, 0.1, 0.1 },  -- red
        presenceZoneColorContested = { 1.0, 0.7, 0.0 },  -- orange
        presenceZoneColorSanctuary = { 0.41, 0.8, 0.94 }, -- light blue
    }
    return defaults[colorKey] or nil
end

local function resolveColors(typeName, cfg, opts)
    opts = opts or {}
    if cfg.specialColor and typeName == "BOSS_EMOTE" then
        local c = (addon.GetPresenceBossEmoteColor and addon.GetPresenceBossEmoteColor()) or addon.PRESENCE_BOSS_EMOTE_COLOR or { 1, 0.2, 0.2 }
        local sc = getCategoryColor("DEFAULT", { 1, 1, 1 })
        return c, sc
    end
    -- Zone-type coloring for zone/subzone changes when enabled
    if (typeName == "ZONE_CHANGE" or typeName == "SUBZONE_CHANGE") and addon.GetDB and addon.GetDB("presenceZoneTypeColoring", false) then
        local ztc = GetZoneTypeColor()
        if ztc then
            local subCat = cfg.subCategory or "DEFAULT"
            local sc = getCategoryColor(subCat, { 1, 1, 1 })
            return ztc, sc
        end
    end
    local cat = cfg.category
    if opts.category and (typeName == "SCENARIO_START" or typeName == "SCENARIO_UPDATE" or typeName == "SCENARIO_COMPLETE" or typeName == "ZONE_CHANGE" or typeName == "SUBZONE_CHANGE") then
        cat = opts.category
    elseif opts.questID then
        if (typeName == "QUEST_COMPLETE" or typeName == "QUEST_UPDATE") and addon.GetQuestBaseCategory then
            local ok, res = pcall(addon.GetQuestBaseCategory, opts.questID)
            cat = (ok and res) or cat
        elseif typeName == "QUEST_ACCEPT" and addon.GetQuestCategory then
            local ok, res = pcall(addon.GetQuestCategory, opts.questID)
            cat = (ok and res) or cat
        end
    end
    local c = getCategoryColor(cat, { 0.9, 0.9, 0.9 })
    local subCat = cfg.subCategory or "DEFAULT"
    local sc = getCategoryColor(subCat, { 1, 1, 1 })
    return c, sc
end

-- ============================================================================
-- FRAME & LAYER CREATION
-- ============================================================================

local function CreateLayer(parent)
    local L = {}
    local shadowA = (addon.SHADOW_A ~= nil) and addon.SHADOW_A or 0.8
    -- Respect Typography shadow settings when available; fall back to addon globals
    local shadowX = (addon.GetDB and tonumber(addon.GetDB("shadowOffsetX", 2))) or addon.SHADOW_OX or 2
    local shadowY = (addon.GetDB and tonumber(addon.GetDB("shadowOffsetY", -2))) or addon.SHADOW_OY or -2

    L.titleShadow = parent:CreateFontString(nil, "BORDER")
    SetSafeFont(L.titleShadow, getPresenceTitleFontPath(), MAIN_SIZE, getPresenceTitleFontOutline())
    L.titleShadow:SetTextColor(0, 0, 0, shadowA)
    L.titleShadow:SetJustifyH("CENTER")

    L.titleText = parent:CreateFontString(nil, "OVERLAY")
    SetSafeFont(L.titleText, getPresenceTitleFontPath(), MAIN_SIZE, getPresenceTitleFontOutline())
    L.titleText:SetTextColor(1, 1, 1, 1)
    L.titleText:SetJustifyH("CENTER")
    L.titleText:SetPoint("TOP", 0, 0)
    L.titleShadow:SetPoint("CENTER", L.titleText, "CENTER", shadowX, shadowY)

    -- Quest-type icon (same atlas as Focus); larger size to match heading scale
    L.questTypeIcon = parent:CreateTexture(nil, "ARTWORK")
    L.questTypeIcon:SetSize(QUEST_ICON_SIZE, QUEST_ICON_SIZE)
    L.questTypeIcon:SetPoint("RIGHT", L.titleText, "LEFT", -6, 0)
    L.questTypeIcon:Hide()

    L.divider = parent:CreateTexture(nil, "ARTWORK")
    L.divider:SetSize(DIVIDER_W, DIVIDER_H)
    L.divider:SetPoint("TOP", 0, -65)
    L.divider:SetColorTexture(1, 1, 1, 1)
    L.divider:SetAlpha(0)

    L.subShadow = parent:CreateFontString(nil, "BORDER")
    SetSafeFont(L.subShadow, getPresenceSubtitleFontPath(), SUB_SIZE, getPresenceSubtitleFontOutline())
    L.subShadow:SetTextColor(0, 0, 0, shadowA)
    L.subShadow:SetJustifyH("CENTER")

    L.subText = parent:CreateFontString(nil, "OVERLAY")
    SetSafeFont(L.subText, getPresenceSubtitleFontPath(), SUB_SIZE, getPresenceSubtitleFontOutline())
    L.subText:SetTextColor(1, 1, 1, 1)  -- neutral; resolved at play via resolveColors
    L.subText:SetJustifyH("CENTER")
    L.subText:SetPoint("TOP", L.divider, "BOTTOM", 0, -10)
    L.subShadow:SetPoint("CENTER", L.subText, "CENTER", shadowX, shadowY)

    L.discoveryShadow = parent:CreateFontString(nil, "BORDER")
    SetSafeFont(L.discoveryShadow, getPresenceSubtitleFontPath(), DISCOVERY_SIZE, getPresenceSubtitleFontOutline())
    L.discoveryShadow:SetTextColor(0, 0, 0, shadowA)
    L.discoveryShadow:SetJustifyH("CENTER")

    L.discoveryText = parent:CreateFontString(nil, "OVERLAY")
    SetSafeFont(L.discoveryText, getPresenceSubtitleFontPath(), DISCOVERY_SIZE, getPresenceSubtitleFontOutline())
    L.discoveryText:SetTextColor(1, 1, 1, 1)  -- neutral; resolved at show via getDiscoveryColor
    L.discoveryText:SetJustifyH("CENTER")
    L.discoveryText:SetPoint("TOP", L.subText, "BOTTOM", 0, -5)
    L.discoveryShadow:SetPoint("CENTER", L.discoveryText, "CENTER", shadowX, shadowY)
    L.discoveryText:SetAlpha(0)
    L.discoveryShadow:SetAlpha(0)

    LockDirectFont(L.titleShadow,     GetPresenceTitleFont)
    LockDirectFont(L.titleText,       GetPresenceTitleFont)
    LockDirectFont(L.subShadow,       GetPresenceSubFont)
    LockDirectFont(L.subText,         GetPresenceSubFont)
    LockDirectFont(L.discoveryShadow, GetPresenceSubFont)
    LockDirectFont(L.discoveryText,   GetPresenceSubFont)

    return L
end

local F, layerA, layerB, curLayer, oldLayer
local anim
local active, activeTitle, activeTypeName
local queue, crossfadeStartAlpha
local subtitleTransition  -- { phase = "fadeOut"|"fadeIn", elapsed = 0, newText = string }
local PlayCinematic

-- Cached at PlayCinematic time so OnUpdate never calls GetDB.
local cachedEntranceDur   = 0.7
local cachedExitDur       = 0.8
local cachedHasDiscovery  = false
local cachedSubGap        = 10  -- px below divider; QUEST_UPDATE uses 12 for compact layout
local cachedCompactLayout = false  -- when true, hide title/divider; show only subtitle (QUEST_UPDATE with presenceHideQuestUpdateTitle)

-- Skip-trackers: avoid redundant layout calls when value hasn't changed.
local lastTitleOffsetY = nil
local lastSubOffsetY   = nil
local lastDividerWidth = nil

local QUEST_UPDATE_DEDUPE_TIME = 1.5
local lastQuestUpdateNorm, lastQuestUpdateTime

-- ============================================================================
-- LIVE DEBUG LOG  (via addon.Log + generic panel from LoggerPanel.lua)
-- ============================================================================

local function IsDebugLive()
    return addon.Log and addon.Log.isEnabled("presence")
end

local presencePanel = addon.Log.createPanel("presence", "Presence Live Debug", {
    maxLines = 500,
    onClose  = function()
        if addon.Presence and addon.Presence.SetDebugLive then
            addon.Presence.SetDebugLive(false)
        end
    end,
})

local function SetDebugLive(v)
    if addon.SetDB then addon.SetDB("presenceDebugLive", v) end
    addon.Log.enableTag("presence", v or nil)
    if v then
        presencePanel.Show()
        addon.Log.debug("presence", "Live debug enabled")
    else
        presencePanel.Hide()
    end
end

local function ToggleDebugLive()
    local next = not IsDebugLive()
    SetDebugLive(next)
    return next
end

-- ============================================================================
-- EASING & ANIMATION HELPERS
-- ============================================================================

local function easeOut(t) return 1 - (1 - t) * (1 - t) end
local function easeIn(t)  return t * t end

local function entEase(elapsed, delay)
    if elapsed < delay then return 0 end
    return easeOut(math.min((elapsed - delay) / ELEMENT_DUR, 1))
end

local function resetLayer(L)
    L.titleText:SetAlpha(0)
    L.titleShadow:SetAlpha(0)
    L.divider:SetAlpha(0)
    L.subText:SetAlpha(0)
    L.subShadow:SetAlpha(0)
    L.discoveryText:SetAlpha(0)
    L.discoveryShadow:SetAlpha(0)
    L.discoveryText:SetText("")
    L.discoveryShadow:SetText("")
    if L.questTypeIcon then L.questTypeIcon:Hide() end
end

--- Apply toast content (fonts, colors, text, icon, layout) to a layer. Shared by PlayCinematic and preview.
--- @param layer table Layer from CreateLayer
--- @param typeName string One of TYPES keys
--- @param title string Heading text
--- @param subtitle string Second line text
--- @param opts table|nil opts.questID, opts.category, opts.showDiscovery (preview: show discovery line for zone)
--- @param forPreview boolean When true, sets alpha 1 and uses static layout; does not consume pendingDiscovery
--- @return nil
local function ApplyToastContentToLayer(layer, typeName, title, subtitle, opts, forPreview)
    opts = opts or {}
    local cfg = TYPES[typeName]
    if not cfg then return end

    local compactLayout = (typeName == "QUEST_UPDATE" or typeName == "SCENARIO_UPDATE") and (addon.GetDB and addon.GetDB("presenceHideQuestUpdateTitle", false))
    local c, sc = resolveColors(typeName, cfg, opts)
    local pcc = addon.GetModuleClassColor and addon.GetModuleClassColor("presence")
    if pcc then
        c = { pcc[1], pcc[2], pcc[3] }
    end
    local variant = getVariant(cfg)
    local mainSz = math.max(12, math.min(72, math.floor(getPrimarySz(variant))))
    local subSz = compactLayout and mainSz or math.max(12, math.min(40, math.floor(getSecondarySz(variant))))

    SetSafeFont(layer.titleText, getPresenceTitleFontPath(), mainSz, getPresenceTitleFontOutline())
    SetSafeFont(layer.titleShadow, getPresenceTitleFontPath(), mainSz, getPresenceTitleFontOutline())
    SetSafeFont(layer.subText, getPresenceSubtitleFontPath(), subSz, getPresenceSubtitleFontOutline())
    SetSafeFont(layer.subShadow, getPresenceSubtitleFontPath(), subSz, getPresenceSubtitleFontOutline())

    layer.titleText:SetTextColor(c[1], c[2], c[3], 1)
    layer.subText:SetTextColor(sc[1], sc[2], sc[3], 1)
    layer.divider:SetVertexColor(c[1], c[2], c[3])

    if compactLayout then
        layer.titleText:SetText("")
        layer.titleShadow:SetText("")
    else
        layer.titleText:SetText(title or "")
        layer.titleShadow:SetText(title or "")
    end
    layer.subText:SetText(subtitle or "")
    layer.subShadow:SetText(subtitle or "")

    resetLayer(layer)
    layer.divider:SetSize(forPreview and DIVIDER_W or 0.01, DIVIDER_H)

    if layer.questTypeIcon then
        local showIcon = false
        local atlas
        local questRelated = (typeName == "QUEST_ACCEPT" or typeName == "QUEST_COMPLETE" or typeName == "QUEST_UPDATE" or typeName == "WORLD_QUEST" or typeName == "WORLD_QUEST_ACCEPT")
        local showIcons = addon.GetDB and addon.GetDB("showPresenceQuestTypeIcons", true)
        if questRelated and opts.questID and addon.GetQuestTypeAtlas and addon.GetDB and showIcons then
            local catForAtlas = "DEFAULT"
            if typeName == "QUEST_COMPLETE" then
                catForAtlas = "COMPLETE"
            elseif (typeName == "QUEST_ACCEPT" or typeName == "QUEST_UPDATE") and addon.GetQuestCategory then
                catForAtlas = addon.GetQuestCategory(opts.questID) or catForAtlas
            elseif typeName == "WORLD_QUEST" or typeName == "WORLD_QUEST_ACCEPT" then
                catForAtlas = "WORLD"
            end
            atlas = addon.GetQuestTypeAtlas(opts.questID, catForAtlas)
            if atlas then showIcon = true end
        end
        if showIcon and atlas then
            layer.questTypeIcon:SetAtlas(atlas)
            local iconMax = (addon.GetDB and addon.GetDB("presenceIconSize", 24)) or QUEST_ICON_SIZE
            local iconSz = compactLayout and ((subSz < iconMax) and subSz or iconMax) or ((mainSz < iconMax) and mainSz or iconMax)
            layer.questTypeIcon:SetSize(iconSz, iconSz)
            layer.questTypeIcon:ClearAllPoints()
            layer.questTypeIcon:SetPoint("RIGHT", compactLayout and layer.subText or layer.titleText, "LEFT", -6, 0)
            layer.questTypeIcon:Show()
        else
            layer.questTypeIcon:Hide()
        end
    end

    local subGap = (cfg.subGap) or 10
    local subAnimDelta = forPreview and 0 or 10
    if forPreview then
        local anchorParent = layer.titleText:GetParent()
        layer.divider:ClearAllPoints()
        layer.divider:SetPoint("CENTER", anchorParent, "CENTER", 0, -PREVIEW_DIVIDER_BELOW_CENTER)
        layer.titleText:ClearAllPoints()
        layer.titleText:SetPoint("BOTTOM", layer.divider, "TOP", 0, PREVIEW_TITLE_ABOVE_DIVIDER)
    else
        layer.divider:ClearAllPoints()
        layer.divider:SetPoint("TOP", 0, -65)
        layer.titleText:ClearAllPoints()
        layer.titleText:SetPoint("TOP", 0, 20)
    end
    layer.subText:ClearAllPoints()
    layer.subText:SetPoint("TOP", layer.divider, "BOTTOM", 0, -(subGap + subAnimDelta))

    local showDiscovery = opts.showDiscovery or (not forPreview and addon.Presence.pendingDiscovery and (typeName == "ZONE_CHANGE" or typeName == "SUBZONE_CHANGE") and (not addon.GetDB or addon.GetDB("showPresenceDiscovery", true)))
    if showDiscovery then
        layer.discoveryText:SetText(addon.L and addon.L["PRESENCE_DISCOVERED"])
        layer.discoveryShadow:SetText(addon.L and addon.L["PRESENCE_DISCOVERED"])
        local dc = getDiscoveryColor()
        layer.discoveryText:SetTextColor(dc[1], dc[2], dc[3], 1)
        layer.discoveryShadow:SetTextColor(0, 0, 0, (addon.SHADOW_A ~= nil) and addon.SHADOW_A or 0.8)
        if not forPreview then addon.Presence.pendingDiscovery = nil end
    end

    if forPreview then
        layer.titleText:SetAlpha(1)
        layer.titleShadow:SetAlpha(0.8)
        layer.divider:SetAlpha(1)
        layer.subText:SetAlpha(1)
        layer.subShadow:SetAlpha(0.8)
        if showDiscovery then
            layer.discoveryText:SetAlpha(1)
            layer.discoveryShadow:SetAlpha(0.8)
        end
    end
end

-- Layout helpers: only call through when the value changes.
local function setTitleOffset(L, offsetY)
    if lastTitleOffsetY ~= offsetY then
        lastTitleOffsetY = offsetY
        L.titleText:ClearAllPoints()
        L.titleText:SetPoint("TOP", 0, offsetY)
    end
end

local function setSubOffset(L, offsetY)
    if lastSubOffsetY ~= offsetY then
        lastSubOffsetY = offsetY
        L.subText:ClearAllPoints()
        L.subText:SetPoint("TOP", L.divider, "BOTTOM", 0, -cachedSubGap + offsetY)
    end
end

local function setDividerWidth(L, w)
    w = math.max(w, 0.01)
    if lastDividerWidth ~= w then
        lastDividerWidth = w
        L.divider:SetSize(w, DIVIDER_H)
    end
end

local function updateEntrance()
    local L  = curLayer
    local e  = anim.elapsed
    local te = entEase(e, DELAY_TITLE)
    local de = entEase(e, DELAY_DIVIDER)
    local se = entEase(e, DELAY_SUBTITLE)

    if cachedCompactLayout then
        L.titleText:SetAlpha(0)
        L.titleShadow:SetAlpha(0)
        if L.questTypeIcon and L.questTypeIcon:IsShown() then L.questTypeIcon:SetAlpha(te) end
    else
        L.titleText:SetAlpha(te)
        L.titleShadow:SetAlpha(te * 0.8)
        if L.questTypeIcon and L.questTypeIcon:IsShown() then L.questTypeIcon:SetAlpha(te) end
        setTitleOffset(L, (1 - te) * 20)
    end

    L.divider:SetAlpha(de * 0.5)
    setDividerWidth(L, DIVIDER_W * de)

    local subAlpha = se
    if subtitleTransition then
        local st = subtitleTransition
        local t = math.min(st.elapsed / SUBTITLE_TRANSITION_DUR, 1)
        local stAlpha = (st.phase == "fadeOut") and (1 - t) or t
        -- Subtitle is still entering; don't go brighter than entrance progress
        subAlpha = math.min(subAlpha, stAlpha)
    end

    L.subText:SetAlpha(subAlpha)
    L.subShadow:SetAlpha(subAlpha * 0.8)
    setSubOffset(L, (1 - se) * (-10))

    if cachedHasDiscovery then
        local dse = entEase(e, DELAY_DISCOVERY)
        L.discoveryText:SetAlpha(dse)
        L.discoveryShadow:SetAlpha(dse * 0.8)
    end
end

local function updateCrossfade()
    local fadeT = math.min(anim.elapsed / CROSSFADE_DUR, 1)
    local fade  = crossfadeStartAlpha * (1 - easeIn(fadeT))
    local fade8 = fade * 0.8
    oldLayer.titleText:SetAlpha(fade)
    oldLayer.titleShadow:SetAlpha(fade8)
    if oldLayer.questTypeIcon and oldLayer.questTypeIcon:IsShown() then oldLayer.questTypeIcon:SetAlpha(fade) end
    oldLayer.divider:SetAlpha(fade * 0.5)
    oldLayer.subText:SetAlpha(fade)
    oldLayer.subShadow:SetAlpha(fade8)
    if (oldLayer.discoveryText:GetText() or "") ~= "" then
        oldLayer.discoveryText:SetAlpha(fade)
        oldLayer.discoveryShadow:SetAlpha(fade8)
    end
    updateEntrance()
end

local function updateExit()
    local L   = curLayer
    local e   = (cachedExitDur > 0) and math.min(anim.elapsed / cachedExitDur, 1) or 1
    local inv = 1 - e
    local inv8 = inv * 0.8

    if cachedCompactLayout then
        L.titleText:SetAlpha(0)
        L.titleShadow:SetAlpha(0)
    else
        L.titleText:SetAlpha(inv)
        L.titleShadow:SetAlpha(inv8)
        setTitleOffset(L, e * 15)
    end

    if L.questTypeIcon and L.questTypeIcon:IsShown() then L.questTypeIcon:SetAlpha(inv) end
    L.divider:SetAlpha(0.5 * inv)
    setDividerWidth(L, DIVIDER_W * inv)

    L.subText:SetAlpha(inv)
    L.subShadow:SetAlpha(inv8)
    setSubOffset(L, e * (-10))

    if cachedHasDiscovery then
        L.discoveryText:SetAlpha(inv)
        L.discoveryShadow:SetAlpha(inv8)
    end
end

local function updateSubtitleTransition(dt)
    if not subtitleTransition or not curLayer then return end
    local st = subtitleTransition
    st.elapsed = st.elapsed + dt
    local L = curLayer
    if st.phase == "fadeOut" then
        local t = math.min(st.elapsed / SUBTITLE_TRANSITION_DUR, 1)
        local alpha = 1 - t
        L.subText:SetAlpha(alpha)
        L.subShadow:SetAlpha(alpha * 0.8)
        if st.elapsed >= SUBTITLE_TRANSITION_DUR then
            L.subText:SetText(st.newText or "")
            L.subShadow:SetText(st.newText or "")
            st.phase = "fadeIn"
            st.elapsed = 0
        end
    else
        local t = math.min(st.elapsed / SUBTITLE_TRANSITION_DUR, 1)
        local alpha = t
        L.subText:SetAlpha(alpha)
        L.subShadow:SetAlpha(alpha * 0.8)
        if st.elapsed >= SUBTITLE_TRANSITION_DUR then
            L.subText:SetAlpha(1)
            L.subShadow:SetAlpha(0.8)
            subtitleTransition = nil
        end
    end
end

local function finalizeEntrance()
    local L = curLayer
    if cachedCompactLayout then
        L.titleText:SetAlpha(0)
        L.titleShadow:SetAlpha(0)
    else
        L.titleText:SetAlpha(1)
        L.titleShadow:SetAlpha(0.8)
        setTitleOffset(L, 0)
    end
    if L.questTypeIcon and L.questTypeIcon:IsShown() then L.questTypeIcon:SetAlpha(1) end
    L.divider:SetAlpha(0.5)
    setDividerWidth(L, DIVIDER_W)
    L.subText:SetAlpha(1)
    L.subShadow:SetAlpha(0.8)
    setSubOffset(L, 0)
    if cachedHasDiscovery then
        L.discoveryText:SetAlpha(1)
        L.discoveryShadow:SetAlpha(0.8)
    end
end

local onComplete
-- OnUpdate: drives entrance/hold/exit phases; adjusts alpha and layout only (no colour or text).
local function PresenceOnUpdate(_, dt)
    if anim.phase == "idle" then return end
    anim.elapsed = anim.elapsed + dt

    if subtitleTransition then
        updateSubtitleTransition(dt)
    end

    if anim.phase == "entrance" then
        if cachedEntranceDur > 0 then
            updateEntrance()
        else
            finalizeEntrance()
        end
        if anim.elapsed >= cachedEntranceDur then
            finalizeEntrance()
            anim.phase   = "hold"
            anim.elapsed = 0
        end
    elseif anim.phase == "crossfade" then
        updateCrossfade()
        if anim.elapsed >= cachedEntranceDur then
            finalizeEntrance()
            resetLayer(oldLayer)
            anim.phase   = "hold"
            anim.elapsed = 0
        end
    elseif anim.phase == "hold" then
        if anim.elapsed >= anim.holdDur then
            anim.phase   = "exit"
            anim.elapsed = 0
        end
    elseif anim.phase == "exit" then
        updateExit()
        if anim.elapsed >= cachedExitDur then
            onComplete()
        end
    end
end

onComplete = function()
    local doneTitle, doneType, doneSub
    if IsDebugLive() then
        doneTitle = activeTitle
        doneType  = activeTypeName
        doneSub   = (curLayer and curLayer.subText and curLayer.subText:GetText()) or ""
    end

    subtitleTransition = nil
    F:SetScript("OnUpdate", nil)
    anim.phase      = "idle"
    active          = nil
    activeTitle     = nil
    activeTypeName  = nil
    resetLayer(curLayer)
    resetLayer(oldLayer)
    F:Hide()

    if doneTitle then
        addon.Log.debug("presence",("Complete %s \"%s\" | \"%s\"; queue=%d"):format(tostring(doneType or "?"), tostring(doneTitle or ""):gsub('"', "'"), tostring(doneSub):gsub('"', "'"), #queue))
    end

    if #queue > 0 then
        local best = 1
        for i = 2, #queue do
            if TYPES[queue[i][1]].pri > TYPES[queue[best][1]].pri then
                best = i
            end
        end
        local nxt = table.remove(queue, best)
        -- Defer to next frame to avoid visible flicker when advancing queue (Hide then Show in same frame)
        C_Timer.After(0, function() PlayCinematic(nxt[1], nxt[2], nxt[3], nxt[4]) end)
    end
end

-- ============================================================================
-- Public functions
-- ============================================================================

--- One-time setup: create frame, layers, animation state. Idempotent.
--- @return nil
local function Init()
    if F then return end

    F = CreateFrame("Frame", "HorizonSuitePresenceFrame", UIParent)
    F:SetSize(FRAME_WIDTH, FRAME_HEIGHT)
    F:SetPoint("TOP", 0, getFrameY())
    F:SetScale(getFrameScale())
    F:Hide()

    layerA   = CreateLayer(F)
    layerB   = CreateLayer(F)
    curLayer = layerA
    oldLayer = layerB

    anim = { phase = "idle", elapsed = 0, holdDur = 4 }
    active = nil
    activeTitle = nil
    activeTypeName = nil
    queue = {}
    crossfadeStartAlpha = 1
    subtitleTransition = nil
    addon.Presence.pendingDiscovery = nil

    addon.Presence.frame = F
    addon.Presence.anim = anim
    addon.Presence.active = function() return active end
    addon.Presence.activeTitle = function() return activeTitle end
    addon.Presence.animPhase = function() return anim.phase end
end

PlayCinematic = function(typeName, title, subtitle, opts)
    local cfg = TYPES[typeName]
    if not cfg then return end

    opts = opts or {}
    if not opts.ignoreTypeEnabled and not IsTypeEnabledForType(typeName) then return end
    cachedCompactLayout = (typeName == "QUEST_UPDATE" or typeName == "SCENARIO_UPDATE") and (addon.GetDB and addon.GetDB("presenceHideQuestUpdateTitle", false))

    if typeName == "QUEST_UPDATE" and subtitle and addon.Presence.NormalizeQuestUpdateText then
        lastQuestUpdateNorm = addon.Presence.NormalizeQuestUpdateText(subtitle)
        lastQuestUpdateTime = GetTime()
    end

    ApplyToastContentToLayer(curLayer, typeName, title, subtitle, opts, false)

    cachedSubGap = (cfg.subGap) or 10
    active        = cfg
    activeTitle   = title
    activeTypeName = typeName
    anim.elapsed = 0
    anim.holdDur = cfg.dur * getHoldScale()

    -- Cache per-animation values; reset trackers so first frame always writes.
    cachedEntranceDur  = getEntranceDur()
    cachedExitDur      = getExitDur()
    cachedHasDiscovery = (curLayer.discoveryText:GetText() or "") ~= ""
    lastTitleOffsetY   = nil
    lastSubOffsetY     = nil
    lastDividerWidth   = nil

    if oldLayer.titleText:GetAlpha() > 0 then
        anim.phase = "crossfade"
    else
        anim.phase = "entrance"
    end

    if IsDebugLive() then
        local src = (opts.source and (" via %s"):format(opts.source)) or ""
        addon.Log.debug("presence",("Play %s \"%s\" | \"%s\" phase=%s%s"):format(typeName, tostring(title or ""):gsub('"', "'"), tostring(subtitle or ""):gsub('"', "'"), anim.phase, src))
    end

    F:SetScript("OnUpdate", PresenceOnUpdate)
    F:SetAlpha(1)
    F:Show()
end

--- Update the subtitle text of the currently displayed cinematic (e.g. subzone soft-update).
--- Uses a quick fade-out/fade-in transition instead of instant swap.
--- @param newSub string New subtitle text
--- @return nil
local function SoftUpdateSubtitle(newSub)
    if not curLayer then return end
    local txt = newSub or ""
    if (curLayer.subText:GetText() or "") == txt then return end
    if subtitleTransition then
        subtitleTransition.newText = txt
    else
        subtitleTransition = { phase = "fadeOut", elapsed = 0, newText = txt }
    end
    if anim.phase == "hold" then
        anim.elapsed = 0
    end
end

--- Show the "Discovered" line on the current layer (zone/subzone discovery).
--- @return nil
local function ShowDiscoveryLine()
    if not curLayer then return end
    if addon.GetDB and not addon.GetDB("showPresenceDiscovery", true) then return end
    curLayer.discoveryText:SetText(addon.L["PRESENCE_DISCOVERED"])
    curLayer.discoveryShadow:SetText(addon.L["PRESENCE_DISCOVERED"])
    local dc = getDiscoveryColor()
    curLayer.discoveryText:SetTextColor(dc[1], dc[2], dc[3], 1)
    curLayer.discoveryShadow:SetTextColor(0, 0, 0, (addon.SHADOW_A ~= nil) and addon.SHADOW_A or 0.8)
    cachedHasDiscovery = true
    if anim.phase == "hold" then
        curLayer.discoveryText:SetAlpha(1)
        curLayer.discoveryShadow:SetAlpha(0.8)
    end
end

--- Set flag so next zone/subzone change shows "Discovered" line.
--- @return nil
local function SetPendingDiscovery()
    addon.Presence.pendingDiscovery = true
end

local function interruptCurrent()
    crossfadeStartAlpha = curLayer.titleText:GetAlpha()
    oldLayer, curLayer = curLayer, oldLayer
    active         = nil
    activeTitle    = nil
    activeTypeName = nil
end

local ZONE_ANIM_TYPES = { ZONE_CHANGE = true, SUBZONE_CHANGE = true }

--- Stops any active zone/subzone animation and purges zone entries from the queue.
local function CancelZoneAnim()
    if not F then return end
    if activeTypeName and ZONE_ANIM_TYPES[activeTypeName] then
        F:SetScript("OnUpdate", nil)
        subtitleTransition = nil
        anim.phase      = "idle"
        anim.elapsed    = 0
        active          = nil
        activeTitle     = nil
        activeTypeName  = nil
        resetLayer(curLayer)
        resetLayer(oldLayer)
        F:Hide()
    end
    if queue then
        local kept = {}
        for _, entry in ipairs(queue) do
            if not ZONE_ANIM_TYPES[entry[1]] then kept[#kept + 1] = entry end
        end
        queue = kept
    end
end

--- Queue or immediately play a cinematic notification.
--- @param typeName string LEVEL_UP, BOSS_EMOTE, ACHIEVEMENT, QUEST_COMPLETE, etc.
--- @param title string Heading text (first line)
--- @param subtitle string Second line text
--- @param opts table|nil Optional; opts.questID for colour/icon, opts.category for SCENARIO_START, opts.source for debug (event name)
--- @return nil
local function QueueOrPlay(typeName, title, subtitle, opts)
    if not F then Init() end
    local cfg = TYPES[typeName]
    if not cfg then return end

    opts = opts or {}
    if not opts.ignoreTypeEnabled and not IsTypeEnabledForType(typeName) then return end

    -- Dedupe: skip QUEST_UPDATE if same normalized text shown recently
    if typeName == "QUEST_UPDATE" and subtitle and addon.Presence.NormalizeQuestUpdateText then
        local norm = addon.Presence.NormalizeQuestUpdateText(subtitle)
        if norm and norm ~= "" and lastQuestUpdateNorm == norm and (GetTime() - (lastQuestUpdateTime or 0)) < QUEST_UPDATE_DEDUPE_TIME then
            return
        end
    end

    if active then
        if cfg.liveUpdate and activeTypeName == typeName
            and (anim.phase == "entrance" or anim.phase == "hold") then
            local newSub = subtitle or ""
            local curSub = (curLayer and curLayer.subText and curLayer.subText:GetText()) or ""
            if newSub ~= curSub then
                if subtitleTransition then
                    subtitleTransition.newText = newSub
                else
                    subtitleTransition = { phase = "fadeOut", elapsed = 0, newText = newSub }
                end
                if anim.phase == "hold" then anim.elapsed = 0 end
                if typeName == "QUEST_UPDATE" and addon.Presence.NormalizeQuestUpdateText then
                    lastQuestUpdateNorm = addon.Presence.NormalizeQuestUpdateText(newSub)
                    lastQuestUpdateTime = GetTime()
                end
                if IsDebugLive() then
                    local src = (opts.source and (" via %s"):format(opts.source)) or ""
                    addon.Log.debug("presence",("LiveUpdate %s \"%s\"%s"):format(typeName, tostring(newSub):gsub('"', "'"), src))
                end
            end
            return
        end

        -- ----------------------------------------------------------------
        -- 2. PRIORITY PREEMPT: incoming event has strictly higher priority
        --    than what is playing.  Interrupt with a crossfade so the more
        --    important notification is never delayed by a queue drain.
        -- ----------------------------------------------------------------
        if cfg.pri > active.pri then
            if IsDebugLive() then
                local src = (opts.source and (" via %s"):format(opts.source)) or ""
                addon.Log.debug("presence",("Preempt %s (pri=%d) over %s (pri=%d)%s"):format(typeName, cfg.pri, activeTypeName or "?", active.pri, src))
            end
            interruptCurrent()
            PlayCinematic(typeName, title, subtitle, opts)
            return
        end

        if #queue < MAX_QUEUE then
            -- Exact-duplicate guard: skip if same type+title is already active
            if activeTitle == title and activeTypeName == typeName then return end

            if cfg.replaceInQueue then
                -- Replace the last same-type entry in the queue instead of appending.
                -- This keeps the queue small during rapid same-type bursts (e.g. mob kills).
                for i = #queue, 1, -1 do
                    if queue[i][1] == typeName then
                        queue[i] = { typeName, title, subtitle, opts }
                        if IsDebugLive() then
                            local src = (opts.source and (" via %s"):format(opts.source)) or ""
                            addon.Log.debug("presence",("QueueReplace[%d] %s | \"%s\"%s"):format(i, typeName, tostring(subtitle or ""):gsub('"', "'"), src))
                        end
                        return
                    end
                end
            end

            queue[#queue + 1] = { typeName, title, subtitle, opts }
            if IsDebugLive() then
                local src = (opts.source and (" via %s"):format(opts.source)) or ""
                addon.Log.debug("presence",("Queued %s | \"%s\" | \"%s\" (q=%d)%s"):format(typeName, tostring(title):gsub('"', "'"), tostring(subtitle or ""):gsub('"', "'"), #queue, src))
            end
        else
            if IsDebugLive() then
                local src = (opts.source and (" via %s"):format(opts.source)) or ""
                addon.Log.debug("presence",("QueueFull – dropped %s%s"):format(typeName, src))
            end
        end
    else
        if IsDebugLive() then
            local src = (opts.source and (" via %s"):format(opts.source)) or ""
            addon.Log.debug("presence",("QueueOrPlay: play %s | \"%s\" | \"%s\"%s"):format(typeName, tostring(title or ""):gsub('"', "'"), tostring(subtitle or ""):gsub('"', "'"), src))
        end
        PlayCinematic(typeName, title, subtitle, opts)
    end
end

--- Remove any queued QUEST_UPDATE entries for the given questID.
--- Called when a quest is disposed (turned in / removed) so stale progress toasts don't play after completion.
--- @param questID number
local function PurgeQueuedQuestUpdates(questID)
    if not questID or not queue or #queue == 0 then return end
    local kept = {}
    for _, entry in ipairs(queue) do
        if not (entry[1] == "QUEST_UPDATE" and entry[4] and entry[4].questID == questID) then
            kept[#kept + 1] = entry
        end
    end
    queue = kept
end

--- Hide frame, clear queue, reset animation state.
--- @return nil
local function HideAndClear()
    if not F then return end
    F:SetScript("OnUpdate", nil)
    anim.phase      = "idle"
    active          = nil
    activeTitle     = nil
    activeTypeName  = nil
    queue = {}
    subtitleTransition = nil
    addon.Presence.pendingDiscovery = nil
    resetLayer(curLayer)
    resetLayer(oldLayer)
    F:Hide()
end

--- Dump Presence internal state to chat for debugging.
--- @return nil
local function DumpDebug()
    if not F then Init() end
    local p = addon.HSPrint or function(msg) print("|cFF00CCFFHorizon Suite:|r " .. tostring(msg or "")) end

    p("|cFF00CCFF--- Presence debug ---|r")
    p("Frame: created, visible=" .. tostring(F and F:IsVisible()))
    p("Module enabled: " .. tostring(addon.IsModuleEnabled and addon:IsModuleEnabled("presence") or "?"))

    if InCombatLockdown then
        p("In combat: " .. tostring(InCombatLockdown()))
    end

    if anim then
        p("Anim phase: " .. tostring(anim.phase) .. ", elapsed: " .. tostring(anim.elapsed) .. ", holdDur: " .. tostring(anim.holdDur))
    end

    if active then
        local sub = (curLayer and curLayer.subText and curLayer.subText:GetText()) or ""
        p("Active: typeName=\"" .. tostring(activeTypeName) .. "\" title=\"" .. tostring(activeTitle) .. "\" subtitle=\"" .. tostring(sub):gsub('"', '\\"') .. "\" pri=" .. tostring(active.pri))
    else
        p("Active: (none)")
    end

    p("Pending discovery: " .. tostring(addon.Presence.pendingDiscovery or false))
    p("Queue: " .. tostring(#queue) .. " entries")
    for i, e in ipairs(queue) do
        p("  [" .. tostring(i) .. "] " .. tostring(e[1]) .. " | \"" .. tostring(e[2]):gsub('"', '\\"') .. "\" | \"" .. tostring(e[3]):gsub('"', '\\"') .. "\"")
    end

    if addon.GetDB then
        p("Options: showPresenceDiscovery=" .. tostring(addon.GetDB("showPresenceDiscovery", true)) .. ", showPresenceQuestTypeIcons=" .. tostring(addon.GetDB("showPresenceQuestTypeIcons", true)) .. ", presenceIconSize=" .. tostring(addon.GetDB("presenceIconSize", 24)))
    end

    if GetZoneText then
        p("Current zone: " .. tostring(GetZoneText()) .. " / " .. tostring(GetSubZoneText()))
    end

    if addon.Presence.DumpBlizzardSuppression then
        addon.Presence.DumpBlizzardSuppression(p)
    end

    if addon.Presence.DumpQuestObjectiveCaches then
        addon.Presence.DumpQuestObjectiveCaches(p)
    end

    p("|cFF00CCFF--- End Presence debug ---|r")
end

-- ============================================================================
-- Exports
-- ============================================================================

--- Re-apply frame position and scale from DB. Call when presence options change.
--- Also re-applies fonts to any currently-showing toast layers so that global font
--- toggle changes take effect immediately without waiting for the next toast.
--- @return nil
local function ApplyPresenceOptions()
    if not F then return end
    F:ClearAllPoints()
    F:SetPoint("TOP", 0, getFrameY())
    F:SetScale(getFrameScale())
    local function reapplyLayerFonts(layer)
        if not layer then return end
        local function fix(fs, getPath, getOutline)
            if not fs then return end
            local _, sz = fs:GetFont()
            if sz then SetSafeFont(fs, getPath(), sz, getOutline()) end
        end
        fix(layer.titleText,       getPresenceTitleFontPath,    getPresenceTitleFontOutline)
        fix(layer.titleShadow,     getPresenceTitleFontPath,    getPresenceTitleFontOutline)
        fix(layer.subText,         getPresenceSubtitleFontPath, getPresenceSubtitleFontOutline)
        fix(layer.subShadow,       getPresenceSubtitleFontPath, getPresenceSubtitleFontOutline)
        fix(layer.discoveryText,   getPresenceSubtitleFontPath, getPresenceSubtitleFontOutline)
        fix(layer.discoveryShadow, getPresenceSubtitleFontPath, getPresenceSubtitleFontOutline)
    end
    reapplyLayerFonts(curLayer)
    reapplyLayerFonts(oldLayer)
end

--- Returns the typeName of the currently playing or holding cinematic.
local function GetActiveTypeName()
    return activeTypeName
end

--- Build preview sample for a toast type. Uses addon.L for localized strings.
--- @param typeName string One of TYPES keys
--- @return table|nil { title, subtitle, opts?, withDiscovery? } or nil if unknown
local function getPreviewSample(typeName)
    if not TYPES[typeName] then return nil end
    local L = addon.L or {}
    if typeName == "ZONE_CHANGE" then
        return { title = "The Waking Shores", subtitle = "Obsidian Citadel", withDiscovery = true }
    end
    if typeName == "SUBZONE_CHANGE" then
        return { title = GetZoneText() or "Valdrakken", subtitle = GetSubZoneText() or "The Seat of Aspects" }
    end
    if typeName == "QUEST_ACCEPT" then
        return { title = L["PRESENCE_QUEST_ACCEPTED"], subtitle = L["PRESENCE_THE_FATE_OF_THE_HORDE"] }
    end
    if typeName == "WORLD_QUEST_ACCEPT" then
        return { title = L["PRESENCE_WORLD_QUEST_ACCEPTED"], subtitle = "Azerite Mining" }
    end
    if typeName == "QUEST_UPDATE" then
        return { title = L["PRESENCE_QUEST_UPDATE"], subtitle = "Boar Pelts: 7/10" }
    end
    if typeName == "QUEST_COMPLETE" then
        return { title = L["PRESENCE_QUEST_COMPLETE"], subtitle = L["PRESENCE_OBJECTIVE_SECURED"] }
    end
    if typeName == "WORLD_QUEST" then
        return { title = L["PRESENCE_WORLD_QUEST_COMPLETE"], subtitle = "Azerite Mining" }
    end
    if typeName == "SCENARIO_START" then
        return { title = "Cinderbrew Meadery", subtitle = "Defend the tavern from attackers", opts = { category = "SCENARIO" } }
    end
    if typeName == "SCENARIO_UPDATE" then
        return { title = "Scenario", subtitle = "Dragon Glyphs: 3/5", opts = { category = "SCENARIO" } }
    end
    if typeName == "SCENARIO_COMPLETE" then
        return { title = L["PRESENCE_SCENARIO_COMPLETE"], subtitle = "Objective completed", opts = { category = "SCENARIO" } }
    end
    if typeName == "ACHIEVEMENT" then
        return { title = L["PRESENCE_ACHIEVEMENT_EARNED"], subtitle = L["PRESENCE_EXPLORING_KHAZ_ALGAR"] }
    end
    if typeName == "ACHIEVEMENT_PROGRESS" then
        return { title = L["PRESENCE_EXPLORING_THE_MIDNIGHT_ISLES"], subtitle = "Dragon Glyphs: 3/5" }
    end
    if typeName == "BOSS_EMOTE" then
        return { title = "Ragnaros", subtitle = "BY FIRE BE PURGED!" }
    end
    if typeName == "LEVEL_UP" then
        local fmt = L["PRESENCE_YOU_HAVE_REACHED_LEVEL_X"]
        return { title = L["PRESENCE_LEVEL_UP"], subtitle = fmt:format("80") }
    end
    if typeName == "RARE_DEFEATED" then
        return { title = L["PRESENCE_RARE_DEFEATED"], subtitle = "Gorged Great-Horn" }
    end
    return nil
end

--- Preview a toast type with sample data. Used by options and slash commands.
--- @param typeName string One of TYPES keys (LEVEL_UP, QUEST_COMPLETE, etc.)
--- @return nil
local function PreviewToast(typeName)
    local sample = getPreviewSample(typeName)
    if not sample then return end
    if sample.withDiscovery and addon.Presence.SetPendingDiscovery then
        addon.Presence.SetPendingDiscovery()
    end
    local opts = {}
    for k, v in pairs(sample.opts or {}) do opts[k] = v end
    opts.ignoreTypeEnabled = true
    QueueOrPlay(typeName, sample.title, sample.subtitle, opts)
end

local previewTargets = setmetatable({}, { __mode = "k" })
local previewPopout

local function getStoredPreviewTypeName()
    if addon.GetDB then
        return addon.GetDB("presencePreviewType", "LEVEL_UP")
    end
    return "LEVEL_UP"
end

local function setStoredPreviewTypeName(typeName)
    if addon.SetDB then
        addon.SetDB("presencePreviewType", typeName)
    end
end

local function RegisterPreviewTarget(owner, refreshFn)
    if owner and refreshFn then
        previewTargets[owner] = refreshFn
    end
end

local function UnregisterPreviewTarget(owner)
    if owner then
        previewTargets[owner] = nil
    end
end

--- Refresh every registered Presence preview surface (embedded and pop-out).
--- @return nil
local function RefreshPreviewTargets()
    for owner, refreshFn in pairs(previewTargets) do
        if owner and refreshFn then
            refreshFn()
        end
    end
end

local function setPreviewTitleOffset(layer, offsetY)
    local parent = layer.titleText:GetParent()
    layer.titleText:ClearAllPoints()
    layer.titleText:SetPoint("BOTTOM", layer.divider, "TOP", 0, PREVIEW_TITLE_ABOVE_DIVIDER + (offsetY or 0))
end

local function setPreviewDividerOffset(layer)
    local parent = layer.divider:GetParent()
    layer.divider:ClearAllPoints()
    layer.divider:SetPoint("CENTER", parent, "CENTER", 0, -PREVIEW_DIVIDER_BELOW_CENTER)
end

local function setPreviewDividerWidth(layer, width)
    layer.divider:SetSize(math.max(width, 0.01), DIVIDER_H)
end

local function setPreviewSubOffset(layer, subGap, offsetY)
    layer.subText:ClearAllPoints()
    layer.subText:SetPoint("TOP", layer.divider, "BOTTOM", 0, -subGap + offsetY)
end

local function finalizePreviewEntrance(previewData)
    local state = previewData and previewData.animState
    local layer = previewData and previewData.layer
    if not state or not layer then return end

    if state.compactLayout then
        layer.titleText:SetAlpha(0)
        layer.titleShadow:SetAlpha(0)
    else
        layer.titleText:SetAlpha(1)
        layer.titleShadow:SetAlpha(0.8)
        setPreviewTitleOffset(layer, 0)
    end
    if layer.questTypeIcon and layer.questTypeIcon:IsShown() then
        layer.questTypeIcon:SetAlpha(1)
    end
    layer.divider:SetAlpha(0.5)
    setPreviewDividerOffset(layer)
    setPreviewDividerWidth(layer, DIVIDER_W)
    layer.subText:SetAlpha(1)
    layer.subShadow:SetAlpha(0.8)
    setPreviewSubOffset(layer, state.subGap, 0)
    if state.hasDiscovery then
        layer.discoveryText:SetAlpha(1)
        layer.discoveryShadow:SetAlpha(0.8)
    end
end

local function StopPreviewAnimation(previewData)
    if not previewData or not previewData.frame then return end
    previewData.frame:SetScript("OnUpdate", nil)
    previewData.animState = nil
end

local function PlayAnimatedPreview(previewData, typeName)
    if not previewData or not previewData.frame or not previewData.layer then return end

    local sample = getPreviewSample(typeName)
    local cfg = typeName and TYPES[typeName]
    if not sample or not cfg then return end

    StopPreviewAnimation(previewData)

    local opts = sample.opts or {}
    if sample.withDiscovery then
        opts.showDiscovery = true
    end

    ApplyToastContentToLayer(previewData.layer, typeName, sample.title, sample.subtitle, opts, true)

    local compactLayout = (typeName == "QUEST_UPDATE" or typeName == "SCENARIO_UPDATE") and (addon.GetDB and addon.GetDB("presenceHideQuestUpdateTitle", false))
    previewData.animState = {
        phase = "prep",
        elapsed = 0,
        holdDur = cfg.dur * getHoldScale(),
        entranceDur = getEntranceDur(),
        exitDur = getExitDur(),
        compactLayout = compactLayout,
        hasDiscovery = opts.showDiscovery == true,
        subGap = (cfg.subGap) or 10,
        typeName = typeName,
    }

    if compactLayout then
        previewData.layer.titleText:SetAlpha(0)
        previewData.layer.titleShadow:SetAlpha(0)
    else
        previewData.layer.titleText:SetAlpha(0)
        previewData.layer.titleShadow:SetAlpha(0)
        setPreviewTitleOffset(previewData.layer, PREVIEW_TITLE_ENTER_DELTA)
    end
    if previewData.layer.questTypeIcon and previewData.layer.questTypeIcon:IsShown() then
        previewData.layer.questTypeIcon:SetAlpha(0)
    end
    previewData.layer.divider:SetAlpha(0)
    setPreviewDividerOffset(previewData.layer)
    setPreviewDividerWidth(previewData.layer, 0.01)
    previewData.layer.subText:SetAlpha(0)
    previewData.layer.subShadow:SetAlpha(0)
    setPreviewSubOffset(previewData.layer, previewData.animState.subGap, PREVIEW_SUB_OFFSET_DELTA)
    if previewData.animState.hasDiscovery then
        previewData.layer.discoveryText:SetAlpha(0)
        previewData.layer.discoveryShadow:SetAlpha(0)
    end

    previewData.frame:Show()
    previewData.frame:SetScript("OnUpdate", function(self, dt)
        local state = previewData.animState
        local layer = previewData.layer
        if not state or not layer then
            self:SetScript("OnUpdate", nil)
            return
        end

        state.elapsed = state.elapsed + dt

        if state.phase == "prep" then
            if state.elapsed >= PREVIEW_PREP_DELAY then
                state.phase = "entrance"
                state.elapsed = 0
            end
        elseif state.phase == "entrance" then
            if state.entranceDur > 0 then
                local te = entEase(state.elapsed, DELAY_TITLE)
                local de = entEase(state.elapsed, DELAY_DIVIDER)
                local se = entEase(state.elapsed, DELAY_SUBTITLE)

                if state.compactLayout then
                    layer.titleText:SetAlpha(0)
                    layer.titleShadow:SetAlpha(0)
                    if layer.questTypeIcon and layer.questTypeIcon:IsShown() then
                        layer.questTypeIcon:SetAlpha(te)
                    end
                else
                    layer.titleText:SetAlpha(te)
                    layer.titleShadow:SetAlpha(te * 0.8)
                    if layer.questTypeIcon and layer.questTypeIcon:IsShown() then
                        layer.questTypeIcon:SetAlpha(te)
                    end
                    setPreviewTitleOffset(layer, (1 - te) * PREVIEW_TITLE_ENTER_DELTA)
                end

                layer.divider:SetAlpha(de * 0.5)
                setPreviewDividerOffset(layer)
                setPreviewDividerWidth(layer, DIVIDER_W * de)
                layer.subText:SetAlpha(se)
                layer.subShadow:SetAlpha(se * 0.8)
                setPreviewSubOffset(layer, state.subGap, (1 - se) * PREVIEW_SUB_OFFSET_DELTA)

                if state.hasDiscovery then
                    local dse = entEase(state.elapsed, DELAY_DISCOVERY)
                    layer.discoveryText:SetAlpha(dse)
                    layer.discoveryShadow:SetAlpha(dse * 0.8)
                end
            else
                finalizePreviewEntrance(previewData)
            end

            if state.elapsed >= state.entranceDur then
                finalizePreviewEntrance(previewData)
                state.phase = "hold"
                state.elapsed = 0
            end
        elseif state.phase == "hold" then
            if state.elapsed >= state.holdDur then
                state.phase = "exit"
                state.elapsed = 0
            end
        elseif state.phase == "exit" then
            local e = (state.exitDur > 0) and math.min(state.elapsed / state.exitDur, 1) or 1
            local inv = 1 - e
            local inv8 = inv * 0.8

            if state.compactLayout then
                layer.titleText:SetAlpha(0)
                layer.titleShadow:SetAlpha(0)
            else
                layer.titleText:SetAlpha(inv)
                layer.titleShadow:SetAlpha(inv8)
                setPreviewTitleOffset(layer, e * PREVIEW_TITLE_EXIT_DELTA)
            end

            if layer.questTypeIcon and layer.questTypeIcon:IsShown() then
                layer.questTypeIcon:SetAlpha(inv)
            end
            layer.divider:SetAlpha(0.5 * inv)
            setPreviewDividerOffset(layer)
            setPreviewDividerWidth(layer, DIVIDER_W * inv)
            layer.subText:SetAlpha(inv)
            layer.subShadow:SetAlpha(inv8)
            setPreviewSubOffset(layer, state.subGap, e * PREVIEW_SUB_OFFSET_DELTA)

            if state.hasDiscovery then
                layer.discoveryText:SetAlpha(inv)
                layer.discoveryShadow:SetAlpha(inv8)
            end

            if state.elapsed >= state.exitDur then
                local doneType = state.typeName
                StopPreviewAnimation(previewData)
                local fn = addon.Presence and addon.Presence.UpdatePreviewFrame
                if fn then fn(previewData, doneType) end
            end
        end
    end)
end

--- Create an embedded preview frame for options. Static display, no animations.
--- @param parent table Parent frame (e.g. options card content)
--- @param opts table|nil opts.scale (default 0.5), opts.getTypeName() returns current type for Refresh
--- @return table|nil { frame, Refresh } or nil if Presence not ready
local function CreatePreviewFrame(parent, opts)
    if not parent then return nil end
    opts = opts or {}
    local scale = opts.scale or PREVIEW_SCALE
    local getTypeName = opts.getTypeName or function() return "LEVEL_UP" end

    local frame = CreateFrame("Frame", nil, parent)
    frame:SetSize(PREVIEW_FRAME_WIDTH, PREVIEW_FRAME_HEIGHT)
    frame:SetScale(scale)

    local bg = frame:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(frame)
    bg:SetColorTexture(0.09, 0.09, 0.12, 0.78)
    if addon.CreateBorder then
        addon.CreateBorder(frame, { 0.18, 0.18, 0.24, 0.95 })
    end

    local layer = CreateLayer(frame)

    local function Refresh()
        local typeName = getTypeName()
        local fn = addon.Presence and addon.Presence.UpdatePreviewFrame
        if fn then fn({ frame = frame, layer = layer }, typeName) end
    end

    return { frame = frame, layer = layer, Refresh = Refresh }
end

--- Update a preview frame with the given toast type. Static, no animation.
--- @param previewData table { frame, layer } from CreatePreviewFrame
--- @param typeName string One of TYPES keys
--- @return nil
local function UpdatePreviewFrame(previewData, typeName)
    if not previewData or not previewData.frame or not previewData.layer then return end
    local sample = getPreviewSample(typeName)
    if not sample then return end

    local opts = sample.opts or {}
    if sample.withDiscovery then opts.showDiscovery = true end

    ApplyToastContentToLayer(previewData.layer, typeName, sample.title, sample.subtitle, opts, true)
    previewData.frame:Show()
end

--- Create a full preview widget used by both options UIs.
--- Includes toast type dropdown, detached-preview button, and embedded sample.
--- @param parent table
--- @param opts table|nil opts.getTypeName, opts.setTypeName, opts.notify, opts.scale
--- @return table|nil { frame, Refresh }
local function CreatePreviewWidget(parent, opts)
    if not parent or not _G.OptionsWidgets_CreateCustomDropdown then return nil end

    opts = opts or {}
    local L = addon.L or {}
    local getTypeName = opts.getTypeName or getStoredPreviewTypeName
    local setTypeName = opts.setTypeName or setStoredPreviewTypeName
    local notify = opts.notify or function() end
    local scale = opts.scale or PREVIEW_SCALE
    local showPopoutButton = opts.showPopoutButton ~= false

    local container = CreateFrame("Frame", nil, parent)
    container:SetHeight(260)

    local dropdownOptsFn = function()
        if addon.GetPresencePreviewDropdownOptions then
            return addon.GetPresencePreviewDropdownOptions()
        end
        return {
            { L["PRESENCE_LEVEL_UP_TOGGLE"], "LEVEL_UP" }
        }
    end

    local previewData
    local dd = _G.OptionsWidgets_CreateCustomDropdown(
        container,
        L["PRESENCE_PREVIEW_TOAST_TYPE"],
        L["PRESENCE_SELECT_A_TOAST_TYPE_PREVIEW"],
        dropdownOptsFn,
        getTypeName,
        function(v)
            setTypeName(v)
            RefreshPreviewTargets()
            notify()
        end,
        nil,
        false,
        nil,
        nil,
        nil
    )
    dd:SetPoint("TOPLEFT", container, "TOPLEFT", 0, 0)
    dd:SetPoint("RIGHT", container, "RIGHT", 0, 0)

    local actionAnchor
    local animateBtn
    if _G.OptionsWidgets_CreateButton then
        animateBtn = _G.OptionsWidgets_CreateButton(container, L["PRESENCE_ANIMATE_PREVIEW"], function()
            if previewData then
                PlayAnimatedPreview(previewData, getTypeName())
            end
            notify()
        end, { height = 22, width = 120, tooltip = L["PRESENCE_PLAY_SELECTED_TOAST_ANIMATION_INSIDE_PREVIEW"] })
        animateBtn:SetPoint("TOPLEFT", dd, "BOTTOMLEFT", 0, -12)
        actionAnchor = animateBtn
    end

    local popoutBtn
    if showPopoutButton and _G.OptionsWidgets_CreateButton then
        popoutBtn = _G.OptionsWidgets_CreateButton(container, L["PRESENCE_OPEN_DETACHED_PREVIEW"], function()
            if addon.Presence and addon.Presence.TogglePreviewPopout then
                addon.Presence.TogglePreviewPopout()
            end
            notify()
        end, { height = 22, width = 170, tooltip = L["PRESENCE_OPEN_A_MOVABLE_PREVIEW_WINDOW_STAYS"] })
        if animateBtn then
            popoutBtn:SetPoint("LEFT", animateBtn, "RIGHT", 8, 0)
            popoutBtn:SetPoint("TOP", animateBtn, "TOP", 0, 0)
        else
            popoutBtn:SetPoint("TOPLEFT", dd, "BOTTOMLEFT", 0, -12)
        end
        actionAnchor = animateBtn or popoutBtn
    end

    previewData = CreatePreviewFrame(container, { scale = scale, getTypeName = getTypeName })
    if previewData and previewData.frame then
        previewData.StopAnimation = function()
            StopPreviewAnimation(previewData)
        end
        local previewAnchor = actionAnchor or dd
        local previewGap = actionAnchor and 14 or 16
        previewData.frame:SetPoint("TOPLEFT", previewAnchor, "BOTTOMLEFT", 0, -previewGap)
        previewData.frame:SetPoint("RIGHT", container, "RIGHT", 0, 0)
        previewData.frame.Refresh = previewData.Refresh
        previewData.Refresh()
    end

    local function doRefresh()
        if previewData and previewData.StopAnimation then previewData.StopAnimation() end
        if dd and dd.Refresh then dd:Refresh() end
        if previewData and previewData.Refresh then previewData.Refresh() end
    end

    function container:Refresh()
        doRefresh()
    end

    container:SetScript("OnShow", function(self)
        RegisterPreviewTarget(self, doRefresh)
        doRefresh()
    end)
    container:SetScript("OnHide", function(self)
        UnregisterPreviewTarget(self)
    end)
    RegisterPreviewTarget(container, doRefresh)

    return { frame = container, Refresh = doRefresh }
end

local function ensurePreviewPopout()
    if previewPopout then
        return previewPopout
    end

    local L = addon.L or {}
    local frame = CreateFrame("Frame", "HorizonSuitePresencePreviewPopout", UIParent, "BackdropTemplate")
    frame:SetSize(PREVIEW_POPOUT_WIDTH, PREVIEW_POPOUT_HEIGHT)
    frame:SetPoint("CENTER", UIParent, "CENTER", 260, 40)
    frame:SetFrameStrata("DIALOG")
    frame:SetToplevel(true)
    frame:SetMovable(true)
    frame:SetClampedToScreen(true)
    frame:EnableMouse(true)
    frame:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        edgeSize = 1,
    })
    frame:SetBackdropColor(0.06, 0.06, 0.08, 0.97)
    frame:SetBackdropBorderColor(0.2, 0.2, 0.26, 0.95)
    frame:Hide()

    if _G.UISpecialFrames then
        local exists = false
        for i = 1, #_G.UISpecialFrames do
            if _G.UISpecialFrames[i] == "HorizonSuitePresencePreviewPopout" then
                exists = true
                break
            end
        end
        if not exists then
            table.insert(_G.UISpecialFrames, "HorizonSuitePresencePreviewPopout")
        end
    end

    local header = CreateFrame("Frame", nil, frame)
    header:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)
    header:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0)
    header:SetHeight(34)
    header:EnableMouse(true)
    header:RegisterForDrag("LeftButton")
    header:SetScript("OnDragStart", function()
        if not InCombatLockdown() then
            frame:StartMoving()
        end
    end)
    header:SetScript("OnDragStop", function()
        frame:StopMovingOrSizing()
    end)

    local headerBg = header:CreateTexture(nil, "BACKGROUND")
    headerBg:SetAllPoints(header)
    headerBg:SetColorTexture(0.08, 0.08, 0.11, 1)

    local title = header:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\FRIZQT__.TTF", 12, "OUTLINE")
    title:SetTextColor(1, 1, 1, 1)
    title:SetText(L["PRESENCE_DETACHED_PREVIEW"])
    title:SetPoint("LEFT", header, "LEFT", 12, 0)

    local subtitle = frame:CreateFontString(nil, "OVERLAY")
    subtitle:SetFont("Fonts\\ARIALN.TTF", 11, "")
    subtitle:SetTextColor(0.65, 0.7, 0.8, 1)
    subtitle:SetJustifyH("LEFT")
    subtitle:SetText(L["PRESENCE_KEEP_OPEN_WHILE_ADJUSTING_TYPOGRAPHY_COLOURS"])
    subtitle:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -44)
    subtitle:SetPoint("RIGHT", frame, "RIGHT", -48, 0)

    local closeBtn = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
    closeBtn:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -4, -3)

    local content = CreateFrame("Frame", nil, frame)
    content:SetPoint("TOPLEFT", frame, "TOPLEFT", 24, -76)
    content:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -24, 0)
    content:SetPoint("BOTTOM", frame, "BOTTOM", 0, 24)

    local widgetData = CreatePreviewWidget(content, {
        getTypeName = getStoredPreviewTypeName,
        setTypeName = function(v)
            setStoredPreviewTypeName(v)
        end,
        notify = function() end,
        scale = 0.65,
        showPopoutButton = false,
    })

    if widgetData and widgetData.frame then
        widgetData.frame:SetPoint("TOPLEFT", content, "TOPLEFT", 0, 0)
        widgetData.frame:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, 0)
    end

    function frame:Refresh()
        if widgetData and widgetData.Refresh then
            widgetData.Refresh()
        end
    end

    frame:SetScript("OnShow", function(self)
        RegisterPreviewTarget(self, function()
            if self.Refresh then self:Refresh() end
        end)
        self:Refresh()
    end)
    frame:SetScript("OnHide", function(self)
        UnregisterPreviewTarget(self)
    end)

    previewPopout = frame
    return frame
end

--- Toggle the detached Presence preview window.
--- @return nil
local function TogglePreviewPopout()
    local frame = ensurePreviewPopout()
    if not frame then return end
    if frame:IsShown() then
        frame:Hide()
        return
    end
    frame:Show()
    frame:Raise()
    if frame.Refresh then frame:Refresh() end
end

addon.Log.registerTag("presence", "presenceDebugLive")

addon.Presence.Init               = Init
addon.Presence.ApplyPresenceOptions = ApplyPresenceOptions
addon.Presence.QueueOrPlay        = QueueOrPlay
addon.Presence.CancelZoneAnim     = CancelZoneAnim
addon.Presence.SoftUpdateSubtitle = SoftUpdateSubtitle
addon.Presence.ShowDiscoveryLine  = ShowDiscoveryLine
addon.Presence.SetPendingDiscovery = SetPendingDiscovery
addon.Presence.HideAndClear       = HideAndClear
addon.Presence.DumpDebug          = DumpDebug
addon.Presence.DebugLog           = function(msg) addon.Log.debug("presence", msg) end
addon.Presence.IsDebugLive        = IsDebugLive
addon.Presence.SetDebugLive       = SetDebugLive
addon.Presence.ToggleDebugLive    = ToggleDebugLive
addon.Presence.ShowDebugPanel     = presencePanel.Show
addon.Presence.HideDebugPanel     = presencePanel.Hide
addon.Presence.GetActiveTypeName  = GetActiveTypeName
addon.Presence.DISCOVERY_WAIT     = 0.15

addon.Presence.IsTypeEnabled        = IsTypeEnabled
addon.Presence.IsTypeEnabledForType  = IsTypeEnabledForType
addon.Presence.IsAnyToastEnabled     = IsAnyToastEnabled
addon.Presence.RequestDebounced     = RequestDebounced
addon.Presence.CancelDebounced      = CancelDebounced
addon.Presence.FormatObjectiveForDisplay = FormatObjectiveForDisplay
addon.Presence.PurgeQueuedQuestUpdates   = PurgeQueuedQuestUpdates
addon.Presence.ShouldSuppressType   = ShouldSuppressType
addon.Presence.TYPE_OPTIONS         = TYPE_OPTIONS
addon.Presence.PreviewToast         = PreviewToast
addon.Presence.PREVIEW_TYPE_ORDER   = PREVIEW_TYPE_ORDER
addon.Presence.PREVIEW_TYPE_LABELS = PREVIEW_TYPE_LABELS
addon.Presence.CreatePreviewFrame   = CreatePreviewFrame
addon.Presence.CreatePreviewWidget  = CreatePreviewWidget
addon.Presence.UpdatePreviewFrame   = UpdatePreviewFrame
addon.Presence.RefreshPreviewTargets = RefreshPreviewTargets
addon.Presence.TogglePreviewPopout  = TogglePreviewPopout
