local ADDON_NAME, RPAL = ...
if type(RPAL) ~= "table" then return end

-- ODYN Hover Log
-- Runtime-only nearby RP history. Nothing in this module is written to SavedVariables.
local MAX_MESSAGES = 80
local CURSOR_PAD_X, CURSOR_PAD_Y = 18, -14
local FOLLOW_BOTTOM_THRESHOLD = 18
local WHEEL_STEP = 54
local FONT = "Fonts\\FRIZQT__.TTF"
local WHITE = "Interface\\Buttons\\WHITE8X8"
local SAY_HEX = "f5f5ef"
local EMOTE_HEX = "ff922b"
local MUTED = {0.57, 0.60, 0.62}
local DEFAULT_ACCENT = {0.45, 0.82, 0.88}

local historyByGUID = {}
local guidByName = {}
RPAL._hoverPerf=RPAL._hoverPerf or {nameOrder={},nameKnown={},nameHead=1,nameTail=0,trpFrameNames={"TRP3_MainFrame","TRP3_MainFrameContainer","TRP3_MainWindow"}}
local panel
local prompt
local quickMenu
local quickMenuGUID
local quickMenuName
local quickMenuHovered=false
local quickMenuOutsideToken=0
local panelCustomPosition=false
local promptGUID
local currentGUID
local currentUnitName
local currentHoverGUID
local currentHoverName
local latchedGUID
local heldMode = false
local hotkeyDown = false
local hotkeyPressToken = 0
local pressStartedGUID
local pressClosingLatch = false
local HOLD_DELAY = 0.26
local identityCache = {}
local identityCacheOrder = {}
local identityCacheKnown = {}
local identityCacheHead = 1
local identityCacheTail = 0
local MAX_IDENTITY_CACHE = 256
local lendEarGUIDs = {}
local lendEarNames = {}
local lendEarLabels = {}
local lendEarCount = 0
local lendEarBaseByLineID = {}
local lendEarBaseOrder = {}
local lendEarBaseKnown = {}
local lendEarBaseHead = 1
local lendEarBaseTail = 0
local MAX_LEND_EAR_BASE_LINES = 2048
local lendEarRefreshPending = false
local lendEarPendingLineID
local panelHovered = false
local mouseoutToken = 0
local MOUSEOUT_GRACE = 0.16
local QUICK_MENU_OUTSIDE_GRACE = 0.50
local HOVER_GUARD_INTERVAL = 0.20
local HOVER_PROMPT_DELAY = 0.40
local lastMentionSoundAt = -100
local lastTargetSoundAt = -100
local selfGUID
local selfNameCache
local selfNameCacheAt = -100
local mentionTokensCache
local mentionTokensCacheAt = -100
local mentionScanText
local mentionScanSpans
local mentionScanAt = -100
local icCache
local icCacheAt = -100
local UpdateHoverGuardState = function() end
local hoverIntentFrame
local hoverIntentGUID
local hoverIntentName
local hoverIntentElapsed = 0

-- Forward declarations used by the rendered-chat transformer below.
local IsSelfSpeaker
local LocalCharacterIsIC
local LendEarSpeaker

local function Now()
    return (GetTime and GetTime()) or 0
end

local function Settings()
    -- The settings table is stable in normal play; prefer the already-created
    -- table instead of re-running Core's default-initialisation path on every
    -- chat event / short-lived hover guard tick.
    if type(RPAL.db) == "table" and type(RPAL.db.settings) == "table" then return RPAL.db.settings end
    if RPAL.GetSettings then return RPAL.GetSettings() end
    return {}
end

local function Clamp(value, low, high, fallback)
    value = tonumber(value) or fallback or low
    if value < low then return low end
    if value > high then return high end
    return value
end

local function IsSecret(value)
    return type(_G.issecretvalue) == "function" and _G.issecretvalue(value)
end

local function SafeString(value)
    if value == nil or IsSecret(value) then return nil end
    if type(value) == "string" then return value ~= "" and value or nil end
    local ok, text = pcall(tostring, value)
    if ok and type(text) == "string" and text ~= "" then return text end
    return nil
end

-- Chat event layouts have changed across WoW versions and some fields can be
-- secret values. Prefer an actual Player-* token wherever it appears instead
-- of trusting one fixed vararg slot.
local function ExtractPlayerGUID(...)
    local argc = select("#", ...)
    for i = 1, argc do
        local value = SafeString(select(i, ...))
        if value and value:match("^Player%-") then
            return value
        end
    end
    return nil
end

local function StripMarkup(value)
    local text = SafeString(value)
    if not text then return "" end
    text = text:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
    text = text:gsub("|T.-|t", ""):gsub("|A.-|a", "")
    return text
end

local function CleanText(value)
    return StripMarkup(value):gsub("^%s+", ""):gsub("%s+$", "")
end

local function NormalizeName(value)
    return CleanText(value):lower():gsub("%s+", "")
end

local function ShortName(value)
    local text = CleanText(value)
    return text:match("^([^-]+)") or text
end

local function SafeUnitExists(unit)
    if not UnitExists then return false end
    local ok, exists = pcall(UnitExists, unit)
    return ok and exists and true or false
end

local function SafeUnitIsPlayer(unit)
    if not SafeUnitExists(unit) then return false end
    if not UnitIsPlayer then return true end
    local ok, isPlayer = pcall(UnitIsPlayer, unit)
    return ok and isPlayer and true or false
end

local function CursorOverWorld()
    local world = _G.WorldFrame
    if not world or type(world.IsMouseOver) ~= "function" then return true end
    local ok, result = pcall(world.IsMouseOver, world)
    return ok and result and true or false
end

local function IsWorldMouseoverPlayer()
    return CursorOverWorld() and SafeUnitIsPlayer("mouseover")
end

local function SafeUnitGUID(unit)
    if not SafeUnitExists(unit) or not UnitGUID then return nil end
    local ok, guid = pcall(UnitGUID, unit)
    if not ok or IsSecret(guid) then return nil end
    return SafeString(guid)
end

local function SafeUnitFullName(unit)
    if not SafeUnitExists(unit) then return nil end
    local name, realm
    if UnitFullName then
        local ok, n, r = pcall(UnitFullName, unit)
        if ok then name, realm = SafeString(n), SafeString(r) end
    end
    if not name and UnitName then
        local ok, n = pcall(UnitName, unit)
        if ok then name = SafeString(n) end
    end
    if not name then return nil end
    if realm and realm ~= "" then return name .. "-" .. realm end
    return name
end

local function MigrateNameBucket(name, guid)
    if not name or not guid then return end
    local nameKey = "NAME:" .. NormalizeName(name)
    local shortKey = "NAME:" .. NormalizeName(ShortName(name))
    local source = historyByGUID[nameKey] or historyByGUID[shortKey]
    if not source then return end
    local target = historyByGUID[guid]
    if not target then
        historyByGUID[guid] = source
        source.guid = guid
        RPAL._hoverPerf.TouchHistoryBucket(source)
        historyByGUID[nameKey] = nil
        historyByGUID[shortKey] = nil
        return
    end
    for _, msg in ipairs(source.messages or {}) do target.messages[#target.messages + 1] = msg end
    RPAL._hoverPerf.TouchHistoryBucket(target)
    table.sort(target.messages, function(a, b) return (a.at or 0) < (b.at or 0) end)
    while #target.messages > MAX_MESSAGES do table.remove(target.messages, 1) end
    historyByGUID[nameKey] = nil
    historyByGUID[shortKey] = nil
end

function RPAL._hoverPerf.RememberNameKey(key, guid)
    if not key or key=="" or not guid then return end
    if not RPAL._hoverPerf.nameKnown[key] then
        RPAL._hoverPerf.nameKnown[key]=true
        RPAL._hoverPerf.nameTail=RPAL._hoverPerf.nameTail+1
        RPAL._hoverPerf.nameOrder[RPAL._hoverPerf.nameTail]=key
        if RPAL._hoverPerf.nameTail-RPAL._hoverPerf.nameHead+1>256 then
            local oldest=RPAL._hoverPerf.nameOrder[RPAL._hoverPerf.nameHead]
            RPAL._hoverPerf.nameOrder[RPAL._hoverPerf.nameHead]=nil
            RPAL._hoverPerf.nameHead=RPAL._hoverPerf.nameHead+1
            if oldest then RPAL._hoverPerf.nameKnown[oldest]=nil;guidByName[oldest]=nil end
        end
    end
    guidByName[key]=guid
end

function RPAL._hoverPerf.TouchHistoryBucket(bucket)
    if bucket then bucket.lastAccessAt=Now() end
end

function RPAL._hoverPerf.ReleaseRenderCache(guid)
    local bucket=guid and historyByGUID[guid]
    if bucket then bucket.renderText=nil end
end

function RPAL._hoverPerf.PruneHistoryBuckets()
    local count=0
    for _ in pairs(historyByGUID) do count=count+1 end
    if count<=72 then return end
    local protected={}
    for _,key in ipairs({currentGUID,currentHoverGUID,latchedGUID,quickMenuGUID}) do if key then protected[key]=true end end
    for key in pairs(lendEarGUIDs) do protected[key]=true end
    while count>72 do
        local oldestKey,oldestAt
        for key,bucket in pairs(historyByGUID) do
            if not protected[key] then
                local at=tonumber(bucket and bucket.lastAccessAt) or 0
                if oldestAt==nil or at<oldestAt then oldestKey,oldestAt=key,at end
            end
        end
        if not oldestKey then break end
        historyByGUID[oldestKey]=nil
        count=count-1
    end
end

local function RememberNameGUID(name, guid)
    name, guid = SafeString(name), SafeString(guid)
    if not name or not guid then return end
    RPAL._hoverPerf.RememberNameKey(NormalizeName(name),guid)
    RPAL._hoverPerf.RememberNameKey(NormalizeName(ShortName(name)),guid)
    MigrateNameBucket(name, guid)
end

local function ResolveGUID(name, guid)
    guid = SafeString(guid)
    if guid then
        RememberNameGUID(name, guid)
        return guid
    end
    name = SafeString(name)
    if not name then return nil end
    return guidByName[NormalizeName(name)] or guidByName[NormalizeName(ShortName(name))]
end

-- Hover Log must never scan the entire LibMSP character cache on mouseover.
-- Large RP realms can have thousands of cached profiles; an O(n) pairs() walk
-- here was the source of the large CPU spike when sweeping across players.
local function MSPRecordFor(name)
    local msp = _G.msp
    if type(msp) ~= "table" or type(msp.char) ~= "table" then return nil end
    name = SafeString(name)
    if not name then return nil end
    local direct = msp.char[name]
    if type(direct) == "table" then return direct end
    local short = ShortName(name)
    direct = msp.char[short]
    if type(direct) == "table" then return direct end
    return nil
end

local function MSPFieldFromRecord(record, key)
    if type(record) ~= "table" then return nil end
    local fields = record.field or record.fields or record
    if type(fields) ~= "table" then return nil end
    local raw = fields[key]
    if type(raw) == "table" then raw = raw.VA or raw.value or raw.TX or raw.text or raw.NA or raw.name end
    return SafeString(raw)
end

local function MSPRawField(name, key)
    local record = MSPRecordFor(name)
    if type(record) ~= "table" then return nil end
    local fields = record.field or record.fields or record
    if type(fields) ~= "table" then return nil end
    return fields[key]
end

local function MSPField(name, key)
    local raw = MSPRawField(name, key)
    if type(raw) == "table" then raw = raw.VA or raw.value or raw.TX or raw.text or raw.NA or raw.name end
    return SafeString(raw)
end

local function ParseHexColor(value)
    if type(value) == "table" then
        local r = tonumber(value.r or value.R or value[1])
        local g = tonumber(value.g or value.G or value[2])
        local b = tonumber(value.b or value.B or value[3])
        if r and g and b then
            if r > 1 or g > 1 or b > 1 then r, g, b = r / 255, g / 255, b / 255 end
            return r, g, b
        end
        value = value.CO or value.color or value.colour or value.CC or value.CH or value.VA or value.value or value.TX or value.text
    end
    local text = SafeString(value)
    if not text then return nil end
    local hex = text:match("|cff(%x%x%x%x%x%x)") or text:match("#(%x%x%x%x%x%x)") or text:match("^%s*(%x%x%x%x%x%x)%s*$")
    if not hex then return nil end
    local r, g, b = tonumber(hex:sub(1, 2), 16), tonumber(hex:sub(3, 4), 16), tonumber(hex:sub(5, 6), 16)
    if not r or not g or not b then return nil end
    return r / 255, g / 255, b / 255
end

local function TRPCharacteristicsForUnit(unit)
    local addon = _G.AddOn_TotalRP3
    local playerClass = type(addon) == "table" and addon.Player or nil
    local static = type(playerClass) == "table" and playerClass.static or nil
    local create = type(static) == "table" and static.CreateFromUnit or nil
    if type(create) ~= "function" or not SafeUnitIsPlayer(unit) then return nil end
    local ok, player = pcall(create, unit)
    if not ok or type(player) ~= "table" or type(player.GetCharacteristics) ~= "function" then return nil end
    local good, characteristics = pcall(player.GetCharacteristics, player)
    if good and type(characteristics) == "table" then return characteristics end
end

local function RemoteIdentityUncached(name, unit)
    -- Prefer LibMSP's direct keyed record when it already exists. It is the
    -- cheapest source and is normally populated by TRP for visible players.
    -- Only fall back to TRP's Player object when visual identity fields are absent.
    local record = MSPRecordFor(name)
    local displayName, title, color, raceName, className, currently

    if record then
        local canonical = CleanText(MSPFieldFromRecord(record, "NA"))
        if canonical ~= "" then displayName = canonical end
        if not displayName or displayName == "" then
            local first = CleanText(MSPFieldFromRecord(record, "FN"))
            local last = CleanText(MSPFieldFromRecord(record, "LN"))
            displayName = first
            if last ~= "" then displayName = displayName ~= "" and (displayName .. " " .. last) or last end
        end
        title = CleanText(MSPFieldFromRecord(record, "NT") or MSPFieldFromRecord(record, "TI") or MSPFieldFromRecord(record, "FT"))
        raceName = CleanText(MSPFieldFromRecord(record, "RA"))
        className = CleanText(MSPFieldFromRecord(record, "CL"))
        currently = CleanText(MSPFieldFromRecord(record, "CU"))
        local fields = record.field or record.fields or record
        local rawColor = type(fields) == "table" and fields.CH or nil
        local r, g, b = ParseHexColor(rawColor or MSPFieldFromRecord(record, "CH"))
        if r then color = {r, g, b} end
    end

    if unit and ((not displayName or displayName == "") or not color or not raceName or raceName == "" or not className or className == "" or not currently or currently == "") then
        local characteristics = TRPCharacteristicsForUnit(unit)
        if characteristics then
            if not displayName or displayName == "" then
                local first = CleanText(characteristics.FN)
                local last = CleanText(characteristics.LN)
                displayName = first
                if last ~= "" then displayName = displayName ~= "" and (displayName .. " " .. last) or last end
            end
            if not title or title == "" then title = CleanText(characteristics.TI or characteristics.FT) end
            if not raceName or raceName == "" then raceName = CleanText(characteristics.RA) end
            if not className or className == "" then className = CleanText(characteristics.CL) end
            if not currently or currently == "" then currently = CleanText(characteristics.CU) end
            if not color then
                local r, g, b = ParseHexColor(characteristics.CH)
                if r then color = {r, g, b} end
            end
        end
    end

    -- Native unit data is a safe fallback when TRP leaves race/class blank.
    -- Keep the visible TRP values when they exist; they may intentionally differ.
    if unit and SafeUnitIsPlayer(unit) then
        if (not raceName or raceName == "") and UnitRace then
            local ok, localized = pcall(UnitRace, unit)
            if ok then raceName = CleanText(localized) end
        end
        if (not className or className == "") and UnitClass then
            local ok, localized, classToken = pcall(UnitClass, unit)
            if ok then
                className = CleanText(localized)
                if not color and classToken and RAID_CLASS_COLORS and RAID_CLASS_COLORS[classToken] then
                    local c = RAID_CLASS_COLORS[classToken]
                    color = {c.r or DEFAULT_ACCENT[1], c.g or DEFAULT_ACCENT[2], c.b or DEFAULT_ACCENT[3]}
                end
            end
        end
    end

    if not displayName or displayName == "" then displayName = ShortName(name) end
    if not color then color = DEFAULT_ACCENT end
    currently = CleanText(currently or ""):gsub("^[Cc][Uu][Rr][Rr][Ee][Nn][Tt][Ll][Yy]%s*:%s*", "")
    return displayName, title or "", color[1], color[2], color[3], raceName or "", className or "", currently or ""
end

local function StoreIdentityCache(key,value)
    if not identityCacheKnown[key]then
        identityCacheKnown[key]=true
        identityCacheTail=identityCacheTail+1
        identityCacheOrder[identityCacheTail]=key
        if identityCacheTail-identityCacheHead+1>MAX_IDENTITY_CACHE then
            local oldest=identityCacheOrder[identityCacheHead]
            identityCacheOrder[identityCacheHead]=nil
            identityCacheHead=identityCacheHead+1
            if oldest then identityCacheKnown[oldest]=nil;identityCache[oldest]=nil end
        end
    end
    identityCache[key]=value
end

local function ClearIdentityCache()
    wipe(identityCache);wipe(identityCacheOrder);wipe(identityCacheKnown);identityCacheHead=1;identityCacheTail=0
end

local function RemoteIdentity(name, unit)
    local guid = unit and SafeUnitGUID(unit) or ResolveGUID(name, nil)
    local key = guid or ("NAME:" .. NormalizeName(name or ""))
    local now = Now()
    local cached = identityCache[key]
    -- Chat can arrive before TRP has exposed a live unit-backed profile. Do not
    -- let that lightweight name-only fallback mask a newly available TRP name,
    -- title or custom class colour when the player is actually under the cursor.
    local wantsUnitRefresh = unit and SafeUnitIsPlayer(unit) and cached and not cached.unitBacked
    if cached and not wantsUnitRefresh and now - (cached.at or 0) < 120 then
        return cached.name, cached.title, cached.r, cached.g, cached.b, cached.raceName, cached.className, cached.currently
    end
    local displayName, title, r, g, b, raceName, className, currently = RemoteIdentityUncached(name, unit)
    StoreIdentityCache(key,{name=displayName,title=title,r=r,g=g,b=b,raceName=raceName,className=className,currently=currently,at=now,unitBacked=unit and SafeUnitIsPlayer(unit) or false})
    return displayName, title, r, g, b, raceName, className, currently
end

local function SelfTRPName()
    local now = Now()
    if selfNameCache and now - selfNameCacheAt < 5 then return selfNameCache end
    selfNameCacheAt = now

    local addon = _G.AddOn_TotalRP3
    local playerClass = type(addon) == "table" and addon.Player or nil
    if type(playerClass) == "table" and type(playerClass.GetCurrentUser) == "function" then
        local ok, user = pcall(playerClass.GetCurrentUser)
        if ok and type(user) == "table" and type(user.GetRoleplayingName) == "function" then
            local good, value = pcall(user.GetRoleplayingName, user)
            value = good and CleanText(value) or ""
            if value ~= "" then selfNameCache = value; return value end
        end
    end

    local msp = _G.msp
    if type(msp) == "table" and type(msp.my) == "table" then
        local canonical = CleanText(msp.my.NA)
        if canonical ~= "" then selfNameCache = canonical; return canonical end
        local first, last = CleanText(msp.my.FN), CleanText(msp.my.LN)
        local full = first
        if last ~= "" then full = full ~= "" and (full .. " " .. last) or last end
        if full ~= "" then selfNameCache = full; return full end
    end

    selfNameCache = ShortName(SafeUnitFullName("player") or (UnitName and UnitName("player")) or "")
    return selfNameCache
end

RPAL._hoverPerf.genericMentionTokens=RPAL._hoverPerf.genericMentionTokens or {
    ["the"]=true,["a"]=true,["an"]=true,["and"]=true,["or"]=true,
    ["sir"]=true,["lady"]=true,["lord"]=true,["dame"]=true,["mr"]=true,["mrs"]=true,["ms"]=true,["miss"]=true,
    ["captain"]=true,["commander"]=true,["master"]=true,["mistress"]=true,["doctor"]=true,["dr"]=true,
    ["professor"]=true,["prof"]=true,["sergeant"]=true,["sgt"]=true,["general"]=true,["chief"]=true,
    ["king"]=true,["queen"]=true,["prince"]=true,["princess"]=true,
}
RPAL._hoverPerf.nicknameFieldLabels=RPAL._hoverPerf.nicknameFieldLabels or {nickname=true,nicknames=true,nick=true,alias=true,aliases=true,aka=true,alsoknownas=true}

function RPAL._hoverPerf.MentionTokenIsSpecific(value)
    value=CleanText(value)
    if #value<3 then return false end
    local key=value:lower():gsub("^%s+",""):gsub("%s+$","")
    if key=="" or RPAL._hoverPerf.genericMentionTokens[key] then return false end
    -- Single punctuation/function-word fragments are never useful mentions.
    if not key:find("[%a%d]") then return false end
    return true
end

function RPAL._hoverPerf.AddImportedNicknameTokens(add)
    local profile=RPAL.char and RPAL.char.profile
    local fields=profile and profile.trpAdditionalFields
    if type(fields)~="table" then return end
    for _,field in ipairs(fields) do
        if type(field)=="table" then
            local label=CleanText(field.label or field.name or ""):lower():gsub("[^%a]","")
            if RPAL._hoverPerf.nicknameFieldLabels[label] then
                local raw=CleanText(field.value or "")
                -- Keep multi-word aliases intact; split only explicit list separators.
                raw=raw:gsub("[\r\n]+",";")
                for piece in raw:gmatch("[^,;/|]+") do
                    piece=CleanText(piece):gsub("^%s*[%-%*]+%s*",""):gsub("^['\"]+",""):gsub("['\"]+$","")
                    if RPAL._hoverPerf.MentionTokenIsSpecific(piece) then add(piece) end
                end
            end
        end
    end
end

local function NameTokensForMention()
    local now = Now()
    if mentionTokensCache and now - mentionTokensCacheAt < 5 then return mentionTokensCache end
    mentionTokensCacheAt = now
    local full = CleanText(SelfTRPName())
    local out, seen = {}, {}
    local function add(value)
        value = CleanText(value)
        if not RPAL._hoverPerf.MentionTokenIsSpecific(value) then return end
        local key = value:lower()
        if not seen[key] then seen[key] = true; out[#out + 1] = value end
    end
    if full ~= "" then
        add(full)
        add(full:match("^([^%s]+)"))
    end
    -- Nickname / Alias / AKA Additional Information imported from TRP is part
    -- of self identity too. This reads only ODYN's already-imported table, so
    -- chat events never walk TRP/MSP profile structures.
    RPAL._hoverPerf.AddImportedNicknameTokens(add)
    table.sort(out, function(a, b) return #a > #b end)
    mentionTokensCache = out
    return out
end

local function MentionSpans(text)
    local original = tostring(text or "")
    local scanNow=Now()
    if mentionScanSpans and original==mentionScanText and scanNow-mentionScanAt<0.25 then return mentionScanSpans end
    local lower = original:lower()
    local spans = {}
    for _, token in ipairs(NameTokensForMention()) do
        local needle, startAt = token:lower(), 1
        while true do
            local a, b = lower:find(needle, startAt, true)
            if not a then break end
            local before = a > 1 and lower:sub(a - 1, a - 1) or ""
            local after = b < #lower and lower:sub(b + 1, b + 1) or ""
            if (before == "" or not before:match("[%w']")) and (after == "" or not after:match("[%w']")) then
                spans[#spans + 1] = {a = a, b = b}
            end
            startAt = b + 1
        end
    end
    table.sort(spans, function(a, b) return a.a == b.a and a.b > b.b or a.a < b.a end)
    local merged, cursor = {}, 0
    for _, span in ipairs(spans) do
        if span.a > cursor then merged[#merged + 1] = span; cursor = span.b end
    end
    mentionScanText=original;mentionScanSpans=merged;mentionScanAt=scanNow
    return merged
end

local function ContainsMention(text)
    return #MentionSpans(text)>0
end

local function HighlightMention(text, baseHex)
    local original=tostring(text or "")
    local spans=MentionSpans(original)
    if #spans==0 then
        if baseHex then return "|cff"..baseHex..original.."|r" end
        return original
    end
    local out,pos={},1
    if baseHex then out[#out+1]="|cff"..baseHex end
    for _,span in ipairs(spans) do
        out[#out+1]=original:sub(pos,span.a-1)
        out[#out+1]="|cffffd34e"..original:sub(span.a,span.b).."|r"
        if baseHex then out[#out+1]="|cff"..baseHex end
        pos=span.b+1
    end
    out[#out+1]=original:sub(pos)
    if baseHex then out[#out+1]="|r" end
    return table.concat(out)
end

-- Focus Messages recolours complete rendered chat lines rather than just arg1. Retail
-- chat frames retain the event metadata used to build each line, so this lets
-- ODYN subdue the sender prefix ("[Name] says:") as well as the body and,
-- importantly, restore already-visible lines the instant the final listener is
-- removed. This work only runs on an RP chat line arriving or on a Focus Messages
-- selection change; there is no polling/OnUpdate path.
local LEND_EAR_DIM_HEX = "4b4e55"
local LEND_EAR_DIM_CODE = "|cff"..LEND_EAR_DIM_HEX
local LEND_EAR_MENTION_CODE = "|cffffd34e"
local TRACKED_CHAT_EVENTS = {
    CHAT_MSG_SAY=true, CHAT_MSG_EMOTE=true, CHAT_MSG_TEXT_EMOTE=true, CHAT_MSG_YELL=true,
}

local function EventArgsGUID(eventArgs)
    if type(eventArgs)~="table" then return nil end
    for i=1,18 do
        local value=SafeString(eventArgs[i])
        if value and value:match("^Player%-") then return value end
    end
    return nil
end

local function DimRenderedChatLine(text)
    text=tostring(text or "")
    -- Protect ODYN's yellow mention marker while forcing every other colour
    -- run (including TRP/class-coloured sender links) into the subdued tone.
    local mentionToken="\001ODYNMENTION\002"
    text=text:gsub("|cffffd34e",mentionToken)
    text=text:gsub("|c%x%x%x%x%x%x%x%x",LEND_EAR_DIM_CODE)
    -- Blizzard/player links often close their own colour run with |r. Reapply
    -- our grey after each reset so the following "says:"/message stays dim.
    text=text:gsub("|r","|r"..LEND_EAR_DIM_CODE)
    text=text:gsub(mentionToken,LEND_EAR_MENTION_CODE)
    return LEND_EAR_DIM_CODE..text.."|r"
end

local function RememberLendEarBase(lineID,base)
    if not lineID then return end
    if not lendEarBaseKnown[lineID] then
        lendEarBaseKnown[lineID]=true
        lendEarBaseTail=lendEarBaseTail+1
        lendEarBaseOrder[lendEarBaseTail]=lineID
        if lendEarBaseTail-lendEarBaseHead+1>MAX_LEND_EAR_BASE_LINES then
            local oldest=lendEarBaseOrder[lendEarBaseHead]
            lendEarBaseOrder[lendEarBaseHead]=nil
            lendEarBaseHead=lendEarBaseHead+1
            if oldest then lendEarBaseKnown[oldest]=nil;lendEarBaseByLineID[oldest]=nil end
        end
    end
    lendEarBaseByLineID[lineID]=base
end

local function ClearLendEarBaseCache()
    wipe(lendEarBaseByLineID);wipe(lendEarBaseOrder);wipe(lendEarBaseKnown);lendEarBaseHead=1;lendEarBaseTail=0
end

local function RefreshLendEarChat(lineID)
    local util=_G.ChatFrameUtil
    if type(util)~="table" or type(util.ForEachChatFrame)~="function" then return false end
    lineID=tonumber(lineID)

    local function Predicate(message,r,g,b,infoID,accessID,typeID,eventName,eventArgs)
        if not TRACKED_CHAT_EVENTS[eventName] or type(eventArgs)~="table" then return false end
        if lineID and tonumber(eventArgs[11])~=lineID then return false end
        return true
    end

    local function Transform(message,r,g,b,infoID,accessID,typeID,eventName,eventArgs,formatter,...)
        local storedLineID=type(eventArgs)=="table" and tonumber(eventArgs[11]) or nil
        local base=storedLineID and lendEarBaseByLineID[storedLineID] or nil
        if not base then
            base=tostring(message or "")
            RememberLendEarBase(storedLineID,base)
        end

        local author=type(eventArgs)=="table" and SafeString(eventArgs[2]) or nil
        local guid=EventArgsGUID(eventArgs)
        local isSelf=author and IsSelfSpeaker(author,guid) or false
        local shouldDim=lendEarCount>0 and Settings().hoverLogEnabled~=false and LocalCharacterIsIC()
            and author and not isSelf and not LendEarSpeaker(author,guid)
        local rendered=shouldDim and DimRenderedChatLine(base) or base
        return rendered,r,g,b,infoID,accessID,typeID,eventName,eventArgs,formatter,...
    end

    local changed=false
    util.ForEachChatFrame(function(chatFrame)
        if chatFrame and type(chatFrame.TransformMessages)=="function" then
            local ok=pcall(chatFrame.TransformMessages,chatFrame,Predicate,Transform)
            if ok then changed=true end
        end
    end)
    return changed
end

local function QueueLendEarLineRefresh(lineID)
    if lendEarCount<=0 then return end
    lineID=tonumber(lineID)
    if lendEarRefreshPending then
        -- Multiple RP lines in one frame coalesce into one bounded refresh.
        -- `false` means refresh all tracked RP lines once on the next frame.
        if lendEarPendingLineID~=lineID then lendEarPendingLineID=false end
        return
    end
    lendEarRefreshPending=true
    lendEarPendingLineID=lineID
    local function Run()
        local target=lendEarPendingLineID
        lendEarRefreshPending=false
        lendEarPendingLineID=nil
        if lendEarCount<=0 then return end
        RefreshLendEarChat(target==false and nil or target)
    end
    if C_Timer and C_Timer.After then C_Timer.After(0.12,Run) else Run() end
end

RPAL.HOVER_LOG_SOUND_CHOICES={
    {key="odyn_chime",label="ODYN Name Mention",fileKind="mention",kits={}},
    {key="emote_tap",label="ODYN Emoted At",fileKind="target",kits={}},
    {key="soft_tick",label="Soft Tick",kits={"IG_MAINMENU_OPTION_CHECKBOX_ON"}},
    {key="soft_toggle",label="Soft Toggle",kits={"IG_MAINMENU_OPTION_CHECKBOX_OFF","IG_MAINMENU_OPTION_CHECKBOX_ON"}},
    {key="journal_open",label="Journal Open",kits={"IG_CHARACTER_INFO_OPEN","IG_QUEST_LIST_OPEN","IG_MAINMENU_OPEN","IG_MAINMENU_OPTION_CHECKBOX_ON"}},
    {key="journal_close",label="Journal Close",kits={"IG_CHARACTER_INFO_CLOSE","IG_QUEST_LIST_CLOSE","IG_MAINMENU_CLOSE","IG_MAINMENU_OPTION_CHECKBOX_ON"}},
    {key="quest_open",label="Quest Page",kits={"IG_QUEST_LIST_OPEN","IG_CHARACTER_INFO_OPEN","IG_MAINMENU_OPTION_CHECKBOX_ON"}},
    {key="quest_close",label="Page Close",kits={"IG_QUEST_LIST_CLOSE","IG_CHARACTER_INFO_CLOSE","IG_MAINMENU_OPTION_CHECKBOX_ON"}},
    {key="menu_open",label="Menu Open",kits={"IG_MAINMENU_OPEN","IG_CHARACTER_INFO_OPEN","IG_MAINMENU_OPTION_CHECKBOX_ON"}},
    {key="menu_close",label="Menu Close",kits={"IG_MAINMENU_CLOSE","IG_CHARACTER_INFO_CLOSE","IG_MAINMENU_OPTION_CHECKBOX_ON"}},
    {key="map_ping",label="Map Ping",kits={"MAP_PING","UI_MAP_WAYPOINT_SUPER_TRACKED_ON","IG_MAINMENU_OPTION_CHECKBOX_ON"}},
    {key="none",label="None",kits={}},
}
RPAL._hoverPerf.soundByKey={}
for _,choice in ipairs(RPAL.HOVER_LOG_SOUND_CHOICES) do RPAL._hoverPerf.soundByKey[choice.key]=choice end

function RPAL.NormalizeHoverLogSoundChoice(key,kind)
    key=tostring(key or "")
    -- HOTFIX18 removes Ready Check from the UI. Existing SavedVariables that
    -- selected it are migrated to the corresponding new default.
    if key=="ready_check" or not RPAL._hoverPerf.soundByKey[key] then
        return kind=="target" and "emote_tap" or "odyn_chime"
    end
    return key
end

function RPAL._hoverPerf.ResolveHoverSoundKit(key)
    local choice=RPAL._hoverPerf.soundByKey[tostring(key or "")]
    if not choice or choice.key=="none" or type(SOUNDKIT)~="table" then return nil end
    for _,constant in ipairs(choice.kits or {}) do
        local kit=SOUNDKIT[constant]
        if kit then return kit end
    end
    return nil
end

function RPAL._hoverPerf.PlayHoverSoundChoice(key,kind)
    if tostring(key or "") == "none" then return false end
    local eventKind=kind=="target" and "target" or "mention"
    if RPAL.IsODYNSoundMuted and RPAL.IsODYNSoundMuted(eventKind) then return false end
    local choice=RPAL._hoverPerf.soundByKey[tostring(key or "")]
    if choice and choice.fileKind and RPAL.PlayODYNSound then
        return RPAL.PlayODYNSound(choice.fileKind)
    end
    local kit=RPAL._hoverPerf.ResolveHoverSoundKit(key)
    if not kit then return false end
    local volume=(RPAL.GetODYNSoundVolume and RPAL.GetODYNSoundVolume(eventKind) or 100)/100
    if type(_G.C_Sound)=="table" and type(_G.C_Sound.PlaySound)=="function" then
        local ok,played=pcall(_G.C_Sound.PlaySound,kit,"SFX",true,false,nil,volume)
        if ok and played then return true end
    end
    if PlaySound then
        local ok=pcall(PlaySound,kit,"SFX",true)
        if ok then return true end
    end
    return false
end

function RPAL.GetHoverLogSoundChoiceLabel(key)
    local choice=RPAL._hoverPerf.soundByKey[tostring(key or "")]
    return choice and choice.label or "None"
end
function RPAL.PreviewHoverLogSoundChoice(key,kind)
    return RPAL._hoverPerf.PlayHoverSoundChoice(key,kind or "mention")
end

local function PlaySmallSound(kind)
    local settings=Settings()
    local key
    if kind=="mention" then
        key=RPAL.NormalizeHoverLogSoundChoice(settings.hoverLogMentionSoundChoice or "odyn_chime","mention")
        settings.hoverLogMentionSoundChoice=key
    else
        key=RPAL.NormalizeHoverLogSoundChoice(settings.hoverLogTargetSoundChoice or "emote_tap","target")
        settings.hoverLogTargetSoundChoice=key
    end
    return RPAL._hoverPerf.PlayHoverSoundChoice(key,kind)
end

IsSelfSpeaker = function(author, guid)
    if not selfGUID then selfGUID = SafeUnitGUID("player") end
    if guid and selfGUID and guid == selfGUID then return true end
    local selfName = SafeUnitFullName("player")
    return selfName and author and NormalizeName(ShortName(selfName)) == NormalizeName(ShortName(author))
end

local function IsDirectedInteraction(eventName, author, guid, targetName, message)
    -- Standard slash emotes (/nod, /peer, /shy, etc.) arrive on
    -- CHAT_MSG_TEXT_EMOTE. CHAT_MSG_EMOTE is accepted too when Blizzard gives
    -- it an explicit playerName2 target, but we never infer a custom /e target
    -- from arbitrary prose.
    if eventName~="CHAT_MSG_TEXT_EMOTE" and eventName~="CHAT_MSG_EMOTE" then return false end
    if IsSelfSpeaker(author,guid) then return false end

    local selfName=SafeUnitFullName("player") or (UnitName and UnitName("player")) or ""
    local selfShort=NormalizeName(ShortName(selfName))
    if selfShort=="" then return false end

    targetName=CleanText(targetName)
    local targetShort=NormalizeName(ShortName(targetName))
    local authorShort=NormalizeName(ShortName(author or ""))
    if targetShort~="" and targetShort~=authorShort then
        return targetShort==selfShort
    end

    -- Retail can occasionally provide an empty/sender playerName2 for a
    -- standard text emote even though the rendered localized line names its
    -- target. For TEXT_EMOTE only, accept the exact WoW player name as a
    -- fallback. We intentionally do not use broad TRP aliases here.
    if eventName=="CHAT_MSG_TEXT_EMOTE" then
        local rawText=CleanText(message or ""):lower()
        local rawName=CleanText(ShortName(selfName)):lower()
        if rawText~="" and rawName~="" then
            local startAt=1
            while true do
                local first,last=rawText:find(rawName,startAt,true)
                if not first then break end
                local before=first>1 and rawText:sub(first-1,first-1) or ""
                local after=last<#rawText and rawText:sub(last+1,last+1) or ""
                if not before:match("[%w_]") and not after:match("[%w_]") then return true end
                startAt=first+1
            end
        end
    end
    return false
end

-- Presentation QA entry point. It deliberately runs the same mention/target
-- recognition gates used by live Hover Log alerts rather than bypassing them
-- with a raw PlaySoundFile call. No fake chat line is stored in history.
function RPAL.TestHoverLogAlert(kind)
    if Settings().hoverLogEnabled==false then return false,"Hover Log is disabled in Settings." end
    kind=tostring(kind or ""):lower()
    if kind=="mention" then
        local own=SelfTRPName()
        if not own or own=="" then own=SafeUnitFullName("player") or (UnitName and UnitName("player")) or "Player" end
        local synthetic="Hey "..tostring(ShortName(own))..", over here."
        if not ContainsMention(synthetic) then return false,"Mention detector did not recognize the current character name." end
        lastMentionSoundAt=-100
        return PlaySmallSound("mention"),"Name mention notification simulated."
    end
    if kind=="target" or kind=="emote" or kind=="interaction" then
        local own=SafeUnitFullName("player") or (UnitName and UnitName("player")) or ""
        if own=="" then return false,"Could not resolve your player name for directed-emote testing." end
        local fakeAuthor="ODYN Tester"
        local fakeGUID="Player-0-00000000"
        if not IsDirectedInteraction("CHAT_MSG_TEXT_EMOTE",fakeAuthor,fakeGUID,own,"ODYN Tester nods at "..tostring(ShortName(own))..".") then return false,"Directed interaction detector did not accept the synthetic target." end
        lastTargetSoundAt=-100
        return PlaySmallSound("target"),"Directed emote notification simulated."
    end
    return false,"Unknown Hover Log alert test."
end

LocalCharacterIsIC = function()
    local now = Now()
    if icCache ~= nil and now - icCacheAt < 0.40 then return icCache end
    icCacheAt = now
    if not RPAL.GetTRPCharacterStatus then icCache = false; return false end
    local ok, status = pcall(RPAL.GetTRPCharacterStatus)
    icCache = ok and status == "IC" or false
    return icCache
end

local function InvalidateICCache()
    icCache = nil
    icCacheAt = -100
end

local function MessageKind(eventName)
    if eventName == "CHAT_MSG_EMOTE" or eventName == "CHAT_MSG_TEXT_EMOTE" then return "emote" end
    return "say"
end

local function StartsWithName(text, name)
    local t, n = tostring(text or ""):lower(), CleanText(name):lower()
    if n == "" or t:sub(1, #n) ~= n then return false end
    local after = t:sub(#n + 1, #n + 1)
    return after == "" or after:match("[%s,:'%-]") ~= nil
end

local function FormatHistoryMessage(eventName, message, author)
    message = CleanText(message)
    if eventName ~= "CHAT_MSG_EMOTE" and eventName ~= "CHAT_MSG_TEXT_EMOTE" then return message end
    local displayName = select(1, RemoteIdentity(author, nil))
    local wowShort = ShortName(author)

    if eventName == "CHAT_MSG_TEXT_EMOTE" then
        if StartsWithName(message, wowShort) then
            return displayName .. message:sub(#wowShort + 1)
        elseif StartsWithName(message, displayName) then
            return message
        end
    end

    if StartsWithName(message, wowShort) or StartsWithName(message, displayName) then return message end
    return displayName .. " " .. message
end

local function AddHistory(eventName, message, author, guid)
    message, author = CleanText(message), CleanText(author)
    if message == "" or author == "" then return end
    guid = ResolveGUID(author, guid) or ("NAME:" .. NormalizeName(author))
    local bucket = historyByGUID[guid]
    if not bucket then
        bucket = {messages = {}, wowName = author, guid = guid, lastAccessAt=Now()}
        historyByGUID[guid] = bucket
        RPAL._hoverPerf.PruneHistoryBuckets()
    end
    bucket.wowName = author
    RPAL._hoverPerf.TouchHistoryBucket(bucket)
    local list = bucket.messages
    list[#list + 1] = {text = FormatHistoryMessage(eventName, message, author), kind = MessageKind(eventName), at = Now()}
    while #list > MAX_MESSAGES do table.remove(list, 1) end
    bucket.renderText = nil
    return guid, bucket
end

local function CreateCorner(parent, point, xDir, yDir)
    local root = CreateFrame("Frame", nil, parent)
    root:SetSize(20, 20); root:SetPoint(point, parent, point, 0, 0)
    root.h = root:CreateTexture(nil, "OVERLAY"); root.h:SetTexture(WHITE); root.h:SetSize(18, 2)
    root.v = root:CreateTexture(nil, "OVERLAY"); root.v:SetTexture(WHITE); root.v:SetSize(2, 18)
    local xPoint = xDir > 0 and "LEFT" or "RIGHT"
    local yPoint = yDir > 0 and "BOTTOM" or "TOP"
    root.h:SetPoint(yPoint .. xPoint, root, yPoint .. xPoint, 0, 0)
    root.v:SetPoint(yPoint .. xPoint, root, yPoint .. xPoint, 0, 0)
    return root
end

local function SetCornerColor(f, r, g, b)
    for _, corner in ipairs(f.corners or {}) do
        corner.h:SetVertexColor(r, g, b, 0.95)
        corner.v:SetVertexColor(r, g, b, 0.95)
    end
end

local function PanelDimensions()
    local s = Settings()
    return math.floor(Clamp(s.hoverLogWidth, 300, 900, 400) + 0.5), math.floor(Clamp(s.hoverLogHeight, 180, 650, 250) + 0.5)
end

local function PanelFontSize()
    return Clamp(Settings().hoverLogFontSize, 11, 21, 16)
end

local function PanelOpacity()
    return Clamp(Settings().hoverLogOpacity, 0.25, 0.85, 0.52)
end

local function FormattedKey(key, fallback)
    key = tostring(key or "")
    if key == "" then return fallback or "?" end
    if RPAL.FormatODYNKey then
        local ok, value = pcall(RPAL.FormatODYNKey, key)
        if ok and value and value ~= "Unbound" then return tostring(value) end
    end
    return key
end

local function QuickActionHotkey()
    -- Keep every Quick Action surface (prompt, menu and Chat Log close hint) on
    -- the exact same live binding. The Keybinds page is the single source of truth.
    if RPAL.GetQuickActionHotkey then
        local ok,key=pcall(RPAL.GetQuickActionHotkey)
        if ok and key~=nil then return tostring(key):upper() end
    end
    return tostring(Settings().quickActionHotkey or "ALT"):upper()
end

local function ChatLogHotkey()
    return tostring(Settings().hoverLogHotkey or "E"):upper()
end

local function QuickInspectHotkey()
    local settings=Settings()
    local binds=type(settings.keybinds)=="table" and settings.keybinds or {}
    return tostring(binds.quickinspect or "Q"):upper()
end

local function QuickWhisperHotkey()
    local settings=Settings()
    local binds=type(settings.keybinds)=="table" and settings.keybinds or {}
    return tostring(binds.quickwhisper or "R"):upper()
end

local function QuickTRPHotkey()
    local settings=Settings()
    local binds=type(settings.keybinds)=="table" and settings.keybinds or {}
    return tostring(binds.quicktrp or "F"):upper()
end

local function LendEarHotkey()
    local settings = Settings()
    local binds = type(settings.keybinds) == "table" and settings.keybinds or {}
    return tostring(binds.lendear or ""):upper()
end

local function SetupFade(frame)
    if frame._odynFadeReady then return end
    frame._odynFadeReady = true
    frame.fadeIn = frame:CreateAnimationGroup()
    local i = frame.fadeIn:CreateAnimation("Alpha")
    i:SetFromAlpha(0); i:SetToAlpha(1); i:SetDuration(0.11)
    frame.fadeIn:SetScript("OnFinished", function() frame:SetAlpha(1) end)
    frame.fadeOut = frame:CreateAnimationGroup()
    local o = frame.fadeOut:CreateAnimation("Alpha")
    o:SetFromAlpha(1); o:SetToAlpha(0); o:SetDuration(0.10)
    frame.fadeOut:SetScript("OnFinished", function()
        frame:Hide(); frame:SetAlpha(1)
        UpdateHoverGuardState()
    end)
end

local function FadeIn(frame)
    if not frame then return end
    SetupFade(frame)
    frame.fadeOut:Stop(); frame.fadeIn:Stop()
    frame:SetAlpha(1); frame:Show(); frame.fadeIn:Play()
    UpdateHoverGuardState()
end

local function FadeOut(frame, immediate)
    if not frame or not frame:IsShown() then return end
    SetupFade(frame)
    frame.fadeIn:Stop(); frame.fadeOut:Stop()
    if immediate then frame:Hide(); frame:SetAlpha(1); UpdateHoverGuardState() else frame:SetAlpha(1); frame.fadeOut:Play() end
end

local wheelOwner=CreateFrame("Frame","ODYN_HoverLogWheelOwner",UIParent)
local wheelButtons={}
local wheelOverridesInstalled=false
local wheelOverridesWanted=false
local ScrollBy

local function EnsureWheelButton(kind)
    if wheelButtons[kind]then return wheelButtons[kind]end
    local name="ODYN_HoverLogWheel"..kind
    local b=CreateFrame("Button",name,UIParent)
    b:SetSize(1,1);b:SetPoint("BOTTOMLEFT",UIParent,"BOTTOMLEFT",-48,-48);b:SetAlpha(0);b:EnableMouse(false);b:RegisterForClicks("LeftButtonUp");b:Show()
    b:SetScript("OnClick",function()
        if panel and panel:IsShown()and ScrollBy then ScrollBy(kind=="Up"and-1 or 1);return end
        -- If combat lockdown delayed removal after the panel closed, preserve
        -- the stock camera-wheel behaviour until bindings can be cleared.
        local camera=kind=="Up"and _G.CameraZoomIn or _G.CameraZoomOut
        if type(camera)=="function"then pcall(camera,1)end
    end)
    wheelButtons[kind]=b
    return b
end

local function ClearWheelOverrides()
    wheelOverridesWanted=false
    if not wheelOverridesInstalled then return true end
    if InCombatLockdown and InCombatLockdown()then return false end
    if type(ClearOverrideBindings)~="function"then return false end
    local ok=pcall(ClearOverrideBindings,wheelOwner)
    if ok then wheelOverridesInstalled=false end
    return ok
end

local function SetFocusMessagesBindingActive(active)
    if type(RPAL.SetFocusMessagesPanelBindingActive)=="function"then
        pcall(RPAL.SetFocusMessagesPanelBindingActive,active==true)
    end
end

ScrollBy=function(direction)
    if not panel or not panel:IsShown() then return end
    local maxScroll = math.max(0, (panel.child:GetHeight() or 0) - (panel.scroll:GetHeight() or 0))
    panel.scroll:SetVerticalScroll(math.max(0, math.min(maxScroll, panel.scroll:GetVerticalScroll() + direction * WHEEL_STEP)))
end

-- While the full log is open, wheel input belongs to its message history even
-- when the cursor is still over the 3D player. A separate override owner keeps
-- these temporary bindings isolated from ODYN's saved user keybinds.
local function InstallWheelOverrides()
    wheelOverridesWanted=true
    if wheelOverridesInstalled then return true end
    if InCombatLockdown and InCombatLockdown()then return false end
    if type(SetOverrideBindingClick)~="function"then return false end
    local up,down=EnsureWheelButton("Up"),EnsureWheelButton("Down")
    local okUp,resultUp=pcall(SetOverrideBindingClick,wheelOwner,true,"MOUSEWHEELUP",up:GetName(),"LeftButton")
    local okDown,resultDown=pcall(SetOverrideBindingClick,wheelOwner,true,"MOUSEWHEELDOWN",down:GetName(),"LeftButton")
    if okUp and resultUp~=false and okDown and resultDown~=false then wheelOverridesInstalled=true;return true end
    if type(ClearOverrideBindings)=="function"then pcall(ClearOverrideBindings,wheelOwner)end
    wheelOverridesInstalled=false
    return false
end

local wheelCombatEvent=CreateFrame("Frame")
wheelCombatEvent:RegisterEvent("PLAYER_REGEN_ENABLED")
wheelCombatEvent:SetScript("OnEvent",function()
    if wheelOverridesWanted and panel and panel:IsShown()then InstallWheelOverrides()else ClearWheelOverrides()end
end)

local UpdateFooter
local ScheduleMouseoutClose
local ScheduleQuickMenuOutsideClose
local HideQuickMenu
local CancelMouseoutClose

local function EnsurePanel()
    if panel then return panel end
    local f = CreateFrame("Frame", "ODYN_HoverLog", UIParent, "BackdropTemplate")
    panel = f
    f:SetFrameStrata("TOOLTIP"); f:SetFrameLevel(80); f:SetClampedToScreen(true); f:EnableMouse(true); f:EnableMouseWheel(true)
    f:SetMovable(true); f:SetResizable(true)
    if f.SetResizeBounds then f:SetResizeBounds(300,180,900,650)
    elseif f.SetMinResize and f.SetMaxResize then f:SetMinResize(300,180); f:SetMaxResize(900,650) end
    f:SetBackdrop({bgFile = WHITE, edgeFile = WHITE, edgeSize = 1}); f:Hide(); SetupFade(f)

    f.inner = f:CreateTexture(nil, "BACKGROUND", nil, 1); f.inner:SetTexture(WHITE); f.inner:SetPoint("TOPLEFT", 1, -1); f.inner:SetPoint("BOTTOMRIGHT", -1, 1)
    f.header = f:CreateTexture(nil, "BACKGROUND", nil, 2); f.header:SetTexture(WHITE); f.header:SetPoint("TOPLEFT", 1, -1); f.header:SetPoint("TOPRIGHT", -1, -1)
    f.rule = f:CreateTexture(nil, "ARTWORK"); f.rule:SetTexture(WHITE); f.rule:SetHeight(1)
    f.title = f:CreateFontString(nil, "OVERLAY"); f.title:SetJustifyH("LEFT"); f.title:SetWordWrap(false)
    f.subtitle = f:CreateFontString(nil, "OVERLAY"); f.subtitle:SetJustifyH("LEFT"); f.subtitle:SetWordWrap(false)

    f.scroll = CreateFrame("ScrollFrame", nil, f)
    f.child = CreateFrame("Frame", nil, f.scroll); f.child:SetHeight(1); f.scroll:SetScrollChild(f.child)
    f.text = f.child:CreateFontString(nil, "OVERLAY"); f.text:SetPoint("TOPLEFT", 0, 0); f.text:SetJustifyH("LEFT"); f.text:SetJustifyV("TOP"); f.text:SetWordWrap(true)

    f.footer = CreateFrame("Frame", nil, f, "BackdropTemplate")
    f.footer:SetBackdrop({bgFile=WHITE, edgeFile=WHITE, edgeSize=1}); f.footer:EnableMouse(true)
    f.footerLabel = f.footer:CreateFontString(nil,"OVERLAY"); f.footerLabel:SetJustifyH("LEFT")
    f.footerKey = CreateFrame("Frame", nil, f.footer, "BackdropTemplate")
    f.footerKey:SetBackdrop({bgFile=WHITE, edgeFile=WHITE, edgeSize=1}); f.footerKey:EnableMouse(false)
    f.footerKeyText = f.footerKey:CreateFontString(nil,"OVERLAY"); f.footerKeyText:SetPoint("CENTER")
    f.closeHint = f.footer:CreateFontString(nil,"OVERLAY"); f.closeHint:SetJustifyH("RIGHT"); f.closeHint:SetWordWrap(false)

    -- The header is the drag surface. The position is intentionally runtime-only:
    -- it stays where the player puts it for this UI session, but /reload restores
    -- the normal cursor-relative opening behaviour instead of trapping the panel.
    f.dragHandle = CreateFrame("Frame", nil, f)
    f.dragHandle:SetPoint("TOPLEFT", 2, -2); f.dragHandle:SetPoint("TOPRIGHT", -2, -2); f.dragHandle:SetHeight(34)
    f.dragHandle:EnableMouse(true); f.dragHandle:RegisterForDrag("LeftButton")
    f.dragHandle:SetScript("OnDragStart", function()
        panelCustomPosition = true
        CancelMouseoutClose()
        f:StartMoving()
    end)
    f.dragHandle:SetScript("OnDragStop", function()
        f:StopMovingOrSizing()
        RPAL._hoverPerf.SavePanelPosition()
    end)

    -- Small bottom-right resize grip. Width/height are real Hover Log settings,
    -- so resizing here and using the Settings sliders always stay in sync.
    f.resizeGrip = CreateFrame("Button", nil, f)
    f.resizeGrip:SetSize(18,18); f.resizeGrip:SetPoint("BOTTOMRIGHT", -2, 2); f.resizeGrip:EnableMouse(true)
    f.resizeGrip.h = f.resizeGrip:CreateTexture(nil,"OVERLAY"); f.resizeGrip.h:SetTexture(WHITE); f.resizeGrip.h:SetSize(11,1); f.resizeGrip.h:SetPoint("BOTTOMRIGHT",-1,4)
    f.resizeGrip.v = f.resizeGrip:CreateTexture(nil,"OVERLAY"); f.resizeGrip.v:SetTexture(WHITE); f.resizeGrip.v:SetSize(1,11); f.resizeGrip.v:SetPoint("BOTTOMRIGHT",-4,1)
    f.resizeGrip:SetScript("OnMouseDown", function(_,button)
        if button~="LeftButton" then return end
        panelCustomPosition = true
        CancelMouseoutClose()
        f:StartSizing("BOTTOMRIGHT")
    end)
    f.resizeGrip:SetScript("OnMouseUp", function(_,button)
        if button~="LeftButton" then return end
        f:StopMovingOrSizing()
        local settings=Settings()
        settings.hoverLogWidth=math.floor(Clamp(f:GetWidth(),300,900,400)+0.5)
        settings.hoverLogHeight=math.floor(Clamp(f:GetHeight(),180,650,250)+0.5)
        RPAL._hoverPerf.SavePanelPosition()
        if RPAL.RefreshHoverLogAppearance then pcall(RPAL.RefreshHoverLogAppearance) end
    end)

    f.animLayer = CreateFrame("Frame", nil, f)
    if f.animLayer.SetClipsChildren then f.animLayer:SetClipsChildren(true) end
    f.animLines = {}
    f.animToken = 0

    f.corners = {
        CreateCorner(f, "TOPLEFT", 1, -1), CreateCorner(f, "TOPRIGHT", -1, -1),
        CreateCorner(f, "BOTTOMLEFT", 1, 1), CreateCorner(f, "BOTTOMRIGHT", -1, 1),
    }

    -- Once the full panel is open it is a real interactive surface. Moving the
    -- cursor from the 3D player onto the panel cancels the pending mouseout
    -- close, so the footer can be clicked and the log can be scrolled normally.
    local function PanelEntered()
        panelHovered = true
        mouseoutToken = mouseoutToken + 1
    end
    local function PanelLeft()
        panelHovered = false
        if ScheduleMouseoutClose then ScheduleMouseoutClose(0.05) end
    end
    f:SetScript("OnEnter", PanelEntered)
    f:SetScript("OnMouseWheel", function(_, delta)
        ScrollBy((tonumber(delta) or 0) > 0 and -1 or 1)
    end)
    f:SetScript("OnLeave", PanelLeft)
    f.dragHandle:SetScript("OnEnter", PanelEntered)
    f.dragHandle:SetScript("OnLeave", PanelLeft)
    f.resizeGrip:SetScript("OnEnter", PanelEntered)
    f.resizeGrip:SetScript("OnLeave", PanelLeft)
    f:HookScript("OnHide", function() ClearWheelOverrides();SetFocusMessagesBindingActive(false) end)
    f.footer:SetScript("OnMouseUp", function(_, button)
        if button == "LeftButton" and RPAL.ToggleLendEarForCurrentPanel then
            RPAL.ToggleLendEarForCurrentPanel()
        end
    end)
    f.footer:SetScript("OnEnter", function()
        panelHovered = true
        mouseoutToken = mouseoutToken + 1
        if f.footerLabel then f.footerLabel:SetAlpha(1) end
    end)
    f.footer:SetScript("OnLeave", function()
        if f.footerLabel then f.footerLabel:SetAlpha(0.96) end
    end)
    return f
end

local function EnsurePrompt()
    if prompt then return prompt end
    local f = CreateFrame("Frame", "ODYN_HoverLogPrompt", UIParent)
    prompt = f
    f:SetSize(460, 78); f:SetFrameStrata("TOOLTIP"); f:SetFrameLevel(78); f:SetClampedToScreen(true); f:EnableMouse(false); f:Hide(); SetupFade(f)

    local function Row(y,h)
        local row=CreateFrame("Frame",nil,f);row._baseY=y;row:SetPoint("TOPLEFT",0,y);row:SetPoint("TOPRIGHT",0,y);row:SetHeight(h);row:EnableMouse(false)
        row.text=row:CreateFontString(nil,"OVERLAY");row.text:SetFont(FONT,9,"OUTLINE");row.text:SetPoint("LEFT",0,0);row.text:SetJustifyH("LEFT");row.text:SetWordWrap(false)
        return row
    end
    -- WoW/TRP already owns the visible nameplate identity. ODYN deliberately
    -- adds only the information that is missing there. Keep the legacy row
    -- fields as hidden compatibility placeholders so older helper paths cannot
    -- accidentally resurrect duplicate name/title/race/class text.
    f.nameRow=Row(0,1);f.nameText=f.nameRow.text;f.nameRow:Hide()
    f.titleRow=Row(0,1);f.titleText=f.titleRow.text;f.titleRow:Hide()
    f.metaRow=Row(0,1);f.metaText=f.metaRow.text;f.metaRow:Hide()
    f.currentRow=Row(0,60);f.currentText=f.currentRow.text
    f.currentLabel=f.currentRow:CreateFontString(nil,"OVERLAY");f.currentLabel:SetFont(FONT,11.8,"OUTLINE");f.currentLabel:SetPoint("TOPLEFT",0,-1);f.currentLabel:SetJustifyH("LEFT");f.currentLabel:SetText("Currently");f.currentLabel:SetTextColor(1.00,.82,0,1);f.currentLabel:SetShadowColor(0,0,0,.98);f.currentLabel:SetShadowOffset(1,-1)
    f.currentText:SetFont(FONT,14.5,"OUTLINE");f.currentText:ClearAllPoints();f.currentText:SetPoint("TOPLEFT",0,-20);f.currentText:SetPoint("TOPRIGHT",-10,-20);f.currentText:SetWidth(448);f.currentText:SetWordWrap(true);if f.currentText.SetMaxLines then pcall(f.currentText.SetMaxLines,f.currentText,0) end;f.currentText:SetJustifyH("LEFT");if f.currentText.SetJustifyV then f.currentText:SetJustifyV("TOP") end;f.currentText:SetShadowColor(0,0,0,.98);f.currentText:SetShadowOffset(1,-1)
    f.actionRow=Row(-68,24);f.actionText=f.actionRow.text;f.actionText:SetFont(FONT,11.8,"OUTLINE");f.actionText:SetJustifyH("LEFT");f.actionText:SetShadowColor(0,0,0,.94);f.actionText:SetShadowOffset(1,-1)

    f.fireflies={}
    local points={{2,6,7},{30,13,5},{70,4,5},{112,12,6},{154,3,4},{202,10,5},{252,1,4}}
    for i,pt in ipairs(points) do
        local tex=f:CreateTexture(nil,"OVERLAY",nil,1);tex:SetTexture("Interface\\AddOns\\"..tostring(ADDON_NAME).."\\Textures\\Celestial\\starlight_glow.tga");tex:SetBlendMode("ADD");tex:SetSize(pt[3],pt[3]);tex:SetPoint("TOPLEFT",f,"TOPLEFT",pt[1],pt[2]);tex:SetAlpha(0);f.fireflies[i]=tex
    end
    f._fireflyPoints=points;f._ambientAge=0;f._ambientStep=0
    return f
end


local function EnsureQuickMenu()
    if quickMenu then return quickMenu end
    local f=CreateFrame("Frame","ODYN_HoverQuickActions",UIParent,"BackdropTemplate")
    quickMenu=f
    f:SetSize(264,176);f:SetFrameStrata("TOOLTIP");f:SetFrameLevel(82);f:SetClampedToScreen(true);f:EnableMouse(true)
    f:SetBackdrop({bgFile=WHITE,edgeFile=WHITE,edgeSize=1});f:Hide();SetupFade(f)

    f.title=f:CreateFontString(nil,"OVERLAY");f.title:SetFont(FONT,10.5,"OUTLINE");f.title:SetText("QUICK ACTIONS");f.title:SetPoint("TOPLEFT",11,-9)
    f.name=f:CreateFontString(nil,"OVERLAY");f.name:SetFont(FONT,8.5,"");f.name:SetPoint("TOPRIGHT",-11,-10);f.name:SetJustifyH("RIGHT");f.name:SetWidth(145);f.name:SetWordWrap(false)
    f.rule=f:CreateTexture(nil,"ARTWORK");f.rule:SetTexture(WHITE);f.rule:SetHeight(1);f.rule:SetPoint("TOPLEFT",10,-29);f.rule:SetPoint("TOPRIGHT",-10,-29)

    local function Row()
        local b=CreateFrame("Button",nil,f,"BackdropTemplate")
        b:SetSize(242,31);b:RegisterForClicks("LeftButtonUp")
        b:SetBackdrop({bgFile=WHITE,edgeFile=WHITE,edgeSize=1})
        b.key=CreateFrame("Frame",nil,b,"BackdropTemplate");b.key:SetSize(43,21);b.key:SetPoint("LEFT",6,0);b.key:SetBackdrop({bgFile=WHITE,edgeFile=WHITE,edgeSize=1});b.key:EnableMouse(false)
        b.keyText=b.key:CreateFontString(nil,"OVERLAY");b.keyText:SetFont(FONT,10.5,"OUTLINE");b.keyText:SetPoint("CENTER")
        b.label=b:CreateFontString(nil,"OVERLAY");b.label:SetFont(FONT,10.5,"OUTLINE");b.label:SetPoint("LEFT",b.key,"RIGHT",9,5);b.label:SetJustifyH("LEFT")
        b.desc=b:CreateFontString(nil,"OVERLAY");b.desc:SetFont(FONT,7.8,"");b.desc:SetPoint("LEFT",b.key,"RIGHT",9,-8);b.desc:SetJustifyH("LEFT")
        b:SetScript("OnEnter",function(self)
            quickMenuHovered=true;mouseoutToken=mouseoutToken+1;quickMenuOutsideToken=quickMenuOutsideToken+1
            if self._enabled~=false then self._hover=true end
            if self._apply then self:_apply() end
        end)
        b:SetScript("OnLeave",function(self)
            quickMenuHovered=false;self._hover=false
            if self._apply then self:_apply() end
            if ScheduleQuickMenuOutsideClose then ScheduleQuickMenuOutsideClose() end
        end)
        return b
    end
    f.inspect=Row()
    f.chat=Row()
    f.whisper=Row()
    f.trp=Row()
    f.inspect:SetScript("OnClick",function()if RPAL.QuickActionInspectPressed then RPAL.QuickActionInspectPressed() end end)
    f.chat:SetScript("OnClick",function()if RPAL.QuickActionChatLogPressed then RPAL.QuickActionChatLogPressed() end end)
    f.whisper:SetScript("OnClick",function()if RPAL.QuickActionWhisperPressed then RPAL.QuickActionWhisperPressed() end end)
    f.trp:SetScript("OnClick",function()if RPAL.QuickActionTRPPressed then RPAL.QuickActionTRPPressed() end end)
    f:SetScript("OnEnter",function()quickMenuHovered=true;mouseoutToken=mouseoutToken+1;quickMenuOutsideToken=quickMenuOutsideToken+1 end)
    f:SetScript("OnLeave",function()quickMenuHovered=false;if ScheduleQuickMenuOutsideClose then ScheduleQuickMenuOutsideClose() end end)
    f:HookScript("OnHide",function()quickMenuHovered=false;quickMenuOutsideToken=quickMenuOutsideToken+1;if RPAL.SetQuickActionContextBindingActive then pcall(RPAL.SetQuickActionContextBindingActive,false) end end)
    return f
end

local function FastPromptAccent(wowName, unit)
    local guid = unit and SafeUnitGUID(unit) or ResolveGUID(wowName, nil)
    local key = guid or ("NAME:" .. NormalizeName(wowName or ""))
    local cached = identityCache[key]
    if cached then return cached.r, cached.g, cached.b end
    -- Cheap direct-key LibMSP fallback only. Do not call TRP Player APIs just
    -- because the cursor crossed a model; full profile resolution waits for open.
    local raw = MSPRawField(wowName, "CH")
    local r, g, b = ParseHexColor(raw)
    if r then return r, g, b end
    return DEFAULT_ACCENT[1], DEFAULT_ACCENT[2], DEFAULT_ACCENT[3]
end

local function ApplyPromptAppearance(wowName, unit)
    local f=EnsurePrompt()
    local showCurrently=Settings().hoverIdentityCardEnabled~=false
    local _,_,r,g,b,_,_,currently=RemoteIdentity(wowName,unit)
    currently=CleanText(currently or "")
    r,g,b=r or DEFAULT_ACCENT[1],g or DEFAULT_ACCENT[2],b or DEFAULT_ACCENT[3]

    -- Do not duplicate the WoW/TRP nameplate. This reveal is now a tiny
    -- companion layer: Currently (when present) plus Quick Actions.
    -- Hidden compatibility rows stay untouched. They have a defensive font from
    -- Row(), but no runtime text writes are needed now that identity duplication
    -- has been removed from the reveal.
    f.nameRow:Hide();f.titleRow:Hide();f.metaRow:Hide()
    local showCurrent=showCurrently and currently~=""
    local currentHeight=0
    if showCurrent then
        -- Match the TRP HUD hierarchy: a quiet label followed by the authored
        -- Currently body in its own warm accent color. Explicit line breaks stay
        -- exactly where the author placed them; only a genuinely overlong line
        -- may wrap for screen safety. Never ellipsize or merge label + value.
        local text=tostring(currently or ""):gsub("\r\n","\n"):gsub("\r","\n")
        f.currentText:SetText(text)
        local explicitLines=1
        for _ in text:gmatch("\n") do explicitLines=explicitLines+1 end
        local measured=0
        if f.currentText.GetStringHeight then
            local ok,value=pcall(f.currentText.GetStringHeight,f.currentText)
            if ok then measured=tonumber(value) or 0 end
        end
        currentHeight=math.max(42,20+explicitLines*18+5,20+measured+5)
        f.currentRow:SetHeight(currentHeight)
        f.currentLabel:Show()
    else
        f.currentText:SetText("")
        f.currentLabel:Hide()
        f.currentRow:SetHeight(1)
    end
    f.currentText:SetTextColor(1.00,1.00,1.00,1)

    local key=FormattedKey(QuickActionHotkey(),"?")
    f.actionText:SetText("["..key.."]  Action Menu")
    f.actionText:SetTextColor(1.00,.82,0,1)
    f.currentRow:SetShown(showCurrent)
    f.currentRow._baseY=0
    f.actionRow._baseY=showCurrent and -(currentHeight+8) or -3
    f:SetHeight(showCurrent and (currentHeight+38) or 32)
    for _,row in ipairs({f.currentRow,f.actionRow}) do
        row:ClearAllPoints();row:SetPoint("TOPLEFT",0,row._baseY or 0);row:SetPoint("TOPRIGHT",0,row._baseY or 0)
    end
    for _,tex in ipairs(f.fireflies or {}) do tex:SetVertexColor(.70+r*.30,.62+g*.30,.28+b*.35,1);tex:Show() end
end

local function ApplyQuickMenuAppearance(wowName,unit)
    local f=EnsureQuickMenu()
    local displayName,_,r,g,b=RemoteIdentity(wowName,unit)
    local guid=(quickMenuGUID or (unit and SafeUnitGUID(unit)))
    local isSelf=IsSelfSpeaker and IsSelfSpeaker(wowName,guid) or false
    local hasODYN=isSelf or (RPAL.IsODYNUser and RPAL.IsODYNUser(wowName) or false)
    f:SetBackdropColor(.018,.022,.028,.96);f:SetBackdropBorderColor(r,g,b,.72)
    f.title:SetTextColor(.55+r*.45,.55+g*.45,.55+b*.45,1)
    f.name:SetText(displayName or ShortName(wowName or ""));f.name:SetTextColor(.72,.75,.77,1)
    f.rule:SetVertexColor(r,g,b,.34)

    local function StyleRow(row,key,label,desc,enabled)
        enabled=enabled~=false
        row._enabled=enabled
        row:SetEnabled(enabled)
        row.keyText:SetText(FormattedKey(key,"?"))
        local keyW=math.max(36,math.min(62,(row.keyText:GetStringWidth() or 24)+16))
        row.key:SetWidth(keyW)
        row.label:ClearAllPoints();row.label:SetPoint("LEFT",row.key,"RIGHT",9,5)
        row.desc:ClearAllPoints();row.desc:SetPoint("LEFT",row.key,"RIGHT",9,-8)
        row.label:SetText(label);row.desc:SetText(desc)
        row._apply=function(self)
            if self._enabled==false then
                self:SetBackdropColor(.025,.028,.032,.78);self:SetBackdropBorderColor(.24,.25,.26,.30)
                self.label:SetTextColor(.42,.44,.45,.88);self.desc:SetTextColor(.32,.34,.35,.82)
                self.key:SetBackdropColor(.035,.038,.042,.78);self.key:SetBackdropBorderColor(.25,.26,.27,.34)
                self.keyText:SetTextColor(.42,.43,.44,.82)
                return
            end
            if self._hover then
                self:SetBackdropColor(.065+r*.055,.07+g*.055,.075+b*.055,.98);self:SetBackdropBorderColor(r,g,b,.92)
                self.label:SetTextColor(.92,.94,.95,1);self.desc:SetTextColor(.76,.80,.82,1)
            else
                self:SetBackdropColor(.035,.04,.047,.96);self:SetBackdropBorderColor(r,g,b,.34)
                self.label:SetTextColor(.87,.89,.90,1);self.desc:SetTextColor(.57,.61,.64,1)
            end
            self.key:SetBackdropColor(.055,.06,.07,.98);self.key:SetBackdropBorderColor(r,g,b,.78)
            self.keyText:SetTextColor(.38+r*.62,.38+g*.62,.38+b*.62,1)
        end
        row:_apply()
    end

    StyleRow(f.inspect,QuickInspectHotkey(),"Inspect Profile",hasODYN and (isSelf and "Preview your ODYN public profile" or "Open this player's ODYN profile") or "ODYN is not detected for this player",hasODYN)

    if isSelf then
        local trpStatus="UNKNOWN"
        if RPAL.GetTRPCharacterStatus then
            local ok,status=pcall(RPAL.GetTRPCharacterStatus)
            if ok and status then trpStatus=tostring(status):upper() end
        end
        local canToggleStatus=(trpStatus=="IC" or trpStatus=="OOC")
        local statusLabel=trpStatus=="IC" and "Go OOC" or (trpStatus=="OOC" and "Go IC" or "IC / OOC")
        local statusDesc=trpStatus=="IC" and "Set your active TRP profile Out of Character" or (trpStatus=="OOC" and "Set your active TRP profile In Character" or "TRP character status is unavailable")
        local sessionActive=RPAL.IsSceneActive and RPAL.IsSceneActive() or false
        local sessionLabel=sessionActive and "Stop Session" or "Start Session"
        local sessionDesc=sessionActive and "Pause or end the current Session" or "Start a new Session immediately"
        StyleRow(f.chat,ChatLogHotkey(),statusLabel,statusDesc,canToggleStatus)
        StyleRow(f.whisper,QuickWhisperHotkey(),sessionLabel,sessionDesc,true)
    else
        StyleRow(f.chat,ChatLogHotkey(),"Chat Log","Open nearby /say and /emote history",true)
        StyleRow(f.whisper,QuickWhisperHotkey(),"Whisper","Open a whisper to this player",true)
    end
    StyleRow(f.trp,QuickTRPHotkey(),"Open TRP",isSelf and "Open your Total RP 3 profile" or "Open this player's Total RP 3 profile",true)

    local rows={f.inspect,f.chat,f.whisper,f.trp}
    f.inspect:Show();f.chat:Show();f.whisper:Show();f.trp:Show()
    for index,row in ipairs(rows) do
        row:ClearAllPoints();row:SetPoint("TOPLEFT",11,-35-((index-1)*34))
    end
    f:SetHeight(176)
    f._isSelf=isSelf
end

local function ApplyAppearance(wowName, unit)
    local f = EnsurePanel()
    local width, height = PanelDimensions()
    local bodySize = PanelFontSize()
    local titleSize = math.min(21, bodySize + 1.5)
    local subtitleSize = math.max(9, bodySize - 3.5)
    local displayName, title, r, g, b = RemoteIdentity(wowName, unit)
    local headerH = title ~= "" and math.max(40, math.floor(titleSize + subtitleSize + 13)) or math.max(32, math.floor(titleSize + 13))
    local footerH=27
    local opacity = PanelOpacity()
    f:SetSize(width, height); f.header:SetHeight(headerH)
    f.rule:ClearAllPoints(); f.rule:SetPoint("TOPLEFT", 10, -headerH); f.rule:SetPoint("TOPRIGHT", -10, -headerH)
    f.title:ClearAllPoints(); f.title:SetPoint("TOPLEFT", 12, -7); f.title:SetPoint("TOPRIGHT", -12, -7); f.title:SetFont(FONT, titleSize, "OUTLINE")
    f.subtitle:ClearAllPoints(); f.subtitle:SetPoint("TOPLEFT", 12, -(titleSize + 10)); f.subtitle:SetPoint("TOPRIGHT", -12, -(titleSize + 10)); f.subtitle:SetFont(FONT, subtitleSize, "")
    f.footer:ClearAllPoints(); f.footer:SetPoint("BOTTOMLEFT",8,7); f.footer:SetPoint("BOTTOMRIGHT",-8,7); f.footer:SetHeight(footerH-7)
    f.footerLabel:SetFont(FONT,math.max(10,bodySize-3),"OUTLINE"); f.footerLabel:ClearAllPoints(); f.footerLabel:SetPoint("LEFT",f.footerKey,"RIGHT",7,0)
    f.footerKeyText:SetFont(FONT,math.max(9.5,bodySize-3.5),"OUTLINE")
    f.closeHint:SetFont(FONT,math.max(8.5,bodySize-5),""); f.closeHint:ClearAllPoints(); f.closeHint:SetPoint("RIGHT",-5,0)
    local closeKey=QuickActionHotkey()
    if closeKey~="" then f.closeHint:SetText("["..FormattedKey(closeKey,"?").."] Close") else f.closeHint:SetText("Interact unbound") end
    f.closeHint:SetTextColor(.56,.60,.62,.92)
    f.dragHandle:SetHeight(headerH)
    f.footerKey:SetHeight(17); f.footerKey:ClearAllPoints(); f.footerKey:SetPoint("LEFT",4,0)
    f.scroll:ClearAllPoints(); f.scroll:SetPoint("TOPLEFT", 10, -headerH - 6); f.scroll:SetPoint("BOTTOMRIGHT", -9, footerH+4)
    f.animLayer:ClearAllPoints(); f.animLayer:SetAllPoints(f.scroll)
    f.child:SetWidth(math.max(20, width - 22)); f.text:SetWidth(math.max(20, width - 24)); f.text:SetFont(FONT, bodySize, ""); f.text:SetSpacing(math.max(2, bodySize * 0.20))

    f.title:SetText(displayName); f.subtitle:SetText(title); f.subtitle:SetShown(title ~= "")
    f.title:SetTextColor(r,g,b,1); f.subtitle:SetTextColor(MUTED[1],MUTED[2],MUTED[3],.95); SetCornerColor(f,r,g,b)
    f:SetBackdropColor(.025,.030,.035,.02); f:SetBackdropBorderColor(r,g,b,.30)
    f.inner:SetColorTexture(.025,.030,.035,opacity); f.header:SetColorTexture(.035,.045,.052,math.min(.82,opacity+.06)); f.rule:SetColorTexture(r,g,b,.26)
    f.footer:SetBackdropColor(.02,.025,.03,math.min(.78,opacity+.12)); f.footer:SetBackdropBorderColor(r,g,b,.24)
    f.footerKey:SetBackdropColor(.055,.06,.07,.92); f.footerKey:SetBackdropBorderColor(r,g,b,.56)
    f.footerKeyText:SetTextColor(r,g,b,1)
    if f.resizeGrip and f.resizeGrip.h then f.resizeGrip.h:SetVertexColor(r,g,b,.72); f.resizeGrip.v:SetVertexColor(r,g,b,.72) end
end

UpdateFooter=function(guid)
    if not panel then return end
    local tagged=guid and lendEarGUIDs[guid]
    panel.footerLabel:SetText(tagged and "Focused  ✓" or "Focus Messages")
    panel.footerLabel:SetTextColor(tagged and 1.00 or .82, tagged and .86 or .82, tagged and .42 or .80,1)
    panel.footer:SetBackdropColor(tagged and .115 or .02, tagged and .080 or .025, tagged and .025 or .03, tagged and .94 or math.min(.78,PanelOpacity()+.12))
    local label=FormattedKey(LendEarHotkey(),"Click")
    panel.footerKeyText:SetText(label)
    local w=math.max(38,math.min(96,(panel.footerKeyText:GetStringWidth() or 26)+14)); panel.footerKey:SetWidth(w)
end

local function IsAtBottom()
    local f=EnsurePanel(); local maxScroll=math.max(0,(f.child:GetHeight() or 0)-(f.scroll:GetHeight() or 0))
    return maxScroll-f.scroll:GetVerticalScroll()<=FOLLOW_BOTTOM_THRESHOLD
end
local function ScrollBottom()
    local f=EnsurePanel(); local maxScroll=math.max(0,(f.child:GetHeight() or 0)-(f.scroll:GetHeight() or 0)); f.scroll:SetVerticalScroll(maxScroll)
end

local function StopLineAnimation(showText)
    if not panel then return end
    panel.animToken=(panel.animToken or 0)+1
    for _,line in ipairs(panel.animLines or {}) do if line.ag then line.ag:Stop() end line:Hide() end
    if showText then panel.text:SetAlpha(1) end
end

local function RenderHistory(guid, forceBottom)
    local f=EnsurePanel(); local bucket=historyByGUID[guid]; if bucket then RPAL._hoverPerf.TouchHistoryBucket(bucket) end; local list=bucket and bucket.messages or nil
    if not list or #list==0 then
        -- Empty history is a valid Hover Log state: the panel still provides
        -- identity + Focus Messages controls, but there is no fake/placeholder line.
        StopLineAnimation(true)
        f.text:SetText("")
        f.text:SetAlpha(1)
        f.child:SetHeight(math.max(1, f.scroll:GetHeight() or 1))
        f.scroll:SetVerticalScroll(0)
        return false
    end
    local wasBottom=IsAtBottom(); local oldScroll=f.scroll:GetVerticalScroll()
    if not bucket.renderText then
        local lines={}
        for _,msg in ipairs(list) do
            local hex=msg.kind=="emote" and EMOTE_HEX or SAY_HEX
            lines[#lines+1]="|cff"..hex..tostring(msg.text or "").."|r"
        end
        bucket.renderText=table.concat(lines,"\n\n")
    end
    f.text:SetText(bucket.renderText); f.text:SetAlpha(1)
    local viewport=f.scroll:GetHeight() or 1; local contentH=math.max(viewport,(f.text:GetStringHeight() or 1)+4); f.child:SetHeight(contentH)
    if forceBottom or wasBottom then ScrollBottom() else local maxScroll=math.max(0,contentH-viewport); f.scroll:SetVerticalScroll(math.min(oldScroll,maxScroll)) end
    return true
end

local function AnimateRecentLines(guid)
    local f=EnsurePanel(); local bucket=historyByGUID[guid]; if bucket then RPAL._hoverPerf.TouchHistoryBucket(bucket) end; local list=bucket and bucket.messages or nil
    if not list or #list==0 then return end
    StopLineAnimation(false); f.text:SetAlpha(0)
    local count=math.min(5,#list); local first=#list-count+1; local bodySize=PanelFontSize(); local lineWidth=math.max(20,(f:GetWidth() or 350)-24); local gap=math.max(5,bodySize*.42)
    local y=2; local visible={}
    for idx=#list,first,-1 do
        local poolIndex=idx-first+1; local line=f.animLines[poolIndex]
        if not line then
            line=f.animLayer:CreateFontString(nil,"OVERLAY"); line:SetJustifyH("LEFT"); line:SetJustifyV("TOP"); line:SetWordWrap(true); f.animLines[poolIndex]=line
        end
        local msg=list[idx]; local hex=msg.kind=="emote" and EMOTE_HEX or SAY_HEX
        line:SetFont(FONT,bodySize,""); line:SetSpacing(math.max(2,bodySize*.20)); line:SetWidth(lineWidth); line:SetText("|cff"..hex..tostring(msg.text or "").."|r")
        local h=math.max(bodySize+2,(line:GetStringHeight() or bodySize+2)); line:ClearAllPoints(); line:SetPoint("BOTTOMLEFT",f.animLayer,"BOTTOMLEFT",-7,y); line:SetHeight(h+2); y=y+h+gap; line:Show(); visible[poolIndex]=line
    end
    local token=(f.animToken or 0)+1; f.animToken=token
    -- poolIndex 1 is the oldest/top-most of the animated recent set.
    for i=1,count do
        local line=f.animLines[i]; if line then
            if line.ag then line.ag:Stop() else
                line.ag=line:CreateAnimationGroup(); line.alphaAnim=line.ag:CreateAnimation("Alpha"); line.moveAnim=line.ag:CreateAnimation("Translation")
                line.alphaAnim:SetFromAlpha(0); line.alphaAnim:SetToAlpha(1); line.alphaAnim:SetDuration(.10)
                line.moveAnim:SetOffset(7,0); line.moveAnim:SetDuration(.10)
            end
            line:SetAlpha(1); local delay=(i-1)*.045; line.alphaAnim:SetStartDelay(delay); line.moveAnim:SetStartDelay(delay); line.ag:Play()
        end
    end
    local total=(count-1)*.045+.12
    if C_Timer and C_Timer.After then C_Timer.After(total,function()
        if not panel or panel.animToken~=token or currentGUID~=guid or not panel:IsShown() then return end
        panel.text:SetAlpha(1); for _,line in ipairs(panel.animLines) do line:Hide() end
    end) else f.text:SetAlpha(1) end
end

local function PositionFrameAtCursor(f, padX, padY)
    local x,y=GetCursorPosition(); local scale=UIParent:GetEffectiveScale(); if not scale or scale<=0 then scale=1 end
    x,y=x/scale,y/scale; local width,height=f:GetWidth(),f:GetHeight(); local sw,sh=UIParent:GetWidth(),UIParent:GetHeight(); f:ClearAllPoints()
    if x+padX+width<=sw then
        if y+padY-height>=0 then f:SetPoint("TOPLEFT",UIParent,"BOTTOMLEFT",x+padX,y+padY) else f:SetPoint("BOTTOMLEFT",UIParent,"BOTTOMLEFT",x+padX,y-padY) end
    else
        if y+padY-height>=0 then f:SetPoint("TOPRIGHT",UIParent,"BOTTOMLEFT",x-padX,y+padY) else f:SetPoint("BOTTOMRIGHT",UIParent,"BOTTOMLEFT",x-padX,y-padY) end
    end
end

function RPAL._hoverPerf.SavedPanelPosition()
    local settings=Settings()
    local x,y=tonumber(settings.hoverLogPositionX),tonumber(settings.hoverLogPositionY)
    if x==nil or y==nil then return nil end
    return x,y
end

function RPAL._hoverPerf.ApplySavedPanelPosition()
    local x,y=RPAL._hoverPerf.SavedPanelPosition()
    if x==nil then return false end
    local f=EnsurePanel();f:ClearAllPoints();f:SetPoint("CENTER",UIParent,"CENTER",x,y);panelCustomPosition=true
    return true
end

function RPAL._hoverPerf.SavePanelPosition()
    if not panel then return end
    local cx,cy=panel:GetCenter();local px,py=UIParent:GetCenter()
    if not cx or not cy or not px or not py then return end
    local settings=Settings()
    settings.hoverLogPositionX=math.floor((cx-px)+.5)
    settings.hoverLogPositionY=math.floor((cy-py)+.5)
    panelCustomPosition=true
end

local function PositionAtCursor()
    if panelCustomPosition then return end
    if RPAL._hoverPerf.ApplySavedPanelPosition() then return end
    PositionFrameAtCursor(EnsurePanel(),CURSOR_PAD_X,CURSOR_PAD_Y)
end
local function PositionPromptAtCursor()
    local f=EnsurePrompt();f:ClearAllPoints()
    local plate
    if C_NamePlate and type(C_NamePlate.GetNamePlateForUnit)=="function" and SafeUnitExists("mouseover") then
        local ok,value=pcall(C_NamePlate.GetNamePlateForUnit,"mouseover")
        if ok and value then plate=value end
    end
    if plate then
        -- The nameplate is only our world-tracking reference. Visually, the
        -- reveal begins around the character model's torso/centre and grows to
        -- the right, rather than attaching to or stacking above nameplate text.
        -- A downward offset approximates the model centre without any per-frame
        -- world-to-screen projection work.
        f:SetPoint("LEFT",plate,"CENTER",42,-104)
    else
        PositionFrameAtCursor(f,CURSOR_PAD_X,CURSOR_PAD_Y)
    end
end
local function PositionQuickMenuAtCursor() PositionFrameAtCursor(EnsureQuickMenu(),CURSOR_PAD_X,CURSOR_PAD_Y) end

local HandleMouseoverChanged
local WorldMouseoverIdentity

local function PanelMouseOver()
    if not panel or not panel:IsShown() or type(panel.IsMouseOver) ~= "function" then return false end
    local ok, over = pcall(panel.IsMouseOver, panel)
    return ok and over and true or false
end

local function QuickMenuMouseOver()
    if not quickMenu or not quickMenu:IsShown() or type(quickMenu.IsMouseOver)~="function" then return false end
    local ok,over=pcall(quickMenu.IsMouseOver,quickMenu)
    return ok and over and true or false
end

CancelMouseoutClose=function()
    mouseoutToken = mouseoutToken + 1
end

ScheduleMouseoutClose=function(delay)
    mouseoutToken = mouseoutToken + 1
    local token = mouseoutToken
    delay = tonumber(delay) or MOUSEOUT_GRACE
    if not C_Timer or not C_Timer.After then
        if not PanelMouseOver() and not QuickMenuMouseOver() and not IsWorldMouseoverPlayer() and HandleMouseoverChanged then HandleMouseoverChanged(false) end
        return
    end
    C_Timer.After(delay, function()
        if token ~= mouseoutToken then return end
        if PanelMouseOver() then panelHovered = true; return end
        if QuickMenuMouseOver() then quickMenuHovered=true;return end
        -- A Chat Log opened from Quick Actions is latched until the Quick Action
        -- key is pressed again. Mouseout must not destroy a panel the player may
        -- have deliberately dragged or resized elsewhere on screen.
        if panel and panel:IsShown() and latchedGUID and currentGUID==latchedGUID then return end
        panelHovered = false
        quickMenuHovered=false
        if IsWorldMouseoverPlayer() then
            if HandleMouseoverChanged then HandleMouseoverChanged(false) end
            return
        end
        latchedGUID=nil
        pressStartedGUID=nil
        currentHoverGUID,currentHoverName=nil,nil
        if prompt then FadeOut(prompt,false) end
        if quickMenu then FadeOut(quickMenu,false) end
        quickMenuGUID,quickMenuName=nil,nil
        if RPAL.SetQuickActionContextBindingActive then pcall(RPAL.SetQuickActionContextBindingActive,false) end
        ClearWheelOverrides();SetFocusMessagesBindingActive(false);StopLineAnimation(true)
        if panel then FadeOut(panel,false) end
        currentGUID,currentUnitName=nil,nil
    end)
end

ScheduleQuickMenuOutsideClose=function()
    quickMenuOutsideToken=quickMenuOutsideToken+1
    local token=quickMenuOutsideToken
    if not quickMenu or not quickMenu:IsShown() then return end

    local function StillOnInteractionSurface()
        if QuickMenuMouseOver() or quickMenuHovered then return true end
        -- The hovered 3D player is part of the interaction surface too. The
        -- menu must not demand that the cursor immediately jumps into the panel.
        -- Only the same player that opened this menu keeps it alive.
        local liveGUID=select(1,WorldMouseoverIdentity())
        return liveGUID~=nil and quickMenuGUID~=nil and liveGUID==quickMenuGUID
    end

    if not C_Timer or not C_Timer.After then
        if not StillOnInteractionSurface() then HideQuickMenu(false);HidePrompt(false) end
        return
    end
    C_Timer.After(QUICK_MENU_OUTSIDE_GRACE,function()
        if token~=quickMenuOutsideToken or not quickMenu or not quickMenu:IsShown() then return end
        if StillOnInteractionSurface() then return end
        HideQuickMenu(false)
        HidePrompt(false)
    end)
end

local function HidePrompt(immediate)
    promptGUID=nil
    if prompt then
        if RPAL._hoverPerf.StopIdentityPromptAmbient then RPAL._hoverPerf.StopIdentityPromptAmbient(prompt) end
        FadeOut(prompt,immediate)
    end
end

HideQuickMenu=function(immediate)
    quickMenuGUID,quickMenuName=nil,nil
    quickMenuHovered=false
    if RPAL.SetQuickActionContextBindingActive then pcall(RPAL.SetQuickActionContextBindingActive,false) end
    if quickMenu then FadeOut(quickMenu,immediate) end
end

local function HidePanel(immediate)
    CancelMouseoutClose()
    panelHovered=false
    ClearWheelOverrides();SetFocusMessagesBindingActive(false);StopLineAnimation(true)
    if panel then FadeOut(panel,immediate) end
    RPAL._hoverPerf.ReleaseRenderCache(currentGUID)
    currentGUID,currentUnitName=nil,nil
end

-- Resolve the hovered 3D player independently from their runtime chat bucket.
-- This matters because Hover Log history is deliberately RAM-only: after /reload
-- the player can still get the Press [key] affordance immediately, while the full
-- log remains unavailable until that character has actually spoken this session.
WorldMouseoverIdentity=function()
    if Settings().hoverLogEnabled==false or not IsWorldMouseoverPlayer() then return nil end
    local guid=SafeUnitGUID("mouseover")
    local wowName=SafeUnitFullName("mouseover")
    if not guid or not wowName then return nil end
    -- Other-player Hover Log remains IC-only, but self Quick Actions must also
    -- work while OOC so the contextual E action can offer Go IC.
    local isSelf=IsSelfSpeaker and IsSelfSpeaker(wowName,guid) or false
    if not isSelf and not LocalCharacterIsIC() then return nil end
    RememberNameGUID(wowName,guid)
    return guid,wowName
end

function RPAL._hoverPerf.TRPUIIsOpen()
    for _,name in ipairs(RPAL._hoverPerf.trpFrameNames) do
        local frame=_G[name]
        if frame and type(frame.IsShown)=="function" then
            local ok,shown=pcall(frame.IsShown,frame)
            if ok and shown then return true end
        end
    end
    local api=rawget(_G,"TRP3_API")
    local nav=type(api)=="table" and api.navigation or nil
    if type(nav)=="table" then
        local frame=nav.mainFrame
        if frame and type(frame.IsShown)=="function" then local ok,shown=pcall(frame.IsShown,frame);if ok and shown then return true end end
        frame=nav.frame
        if frame and type(frame.IsShown)=="function" then local ok,shown=pcall(frame.IsShown,frame);if ok and shown then return true end end
    end
    return false
end

function RPAL._hoverPerf.ODYNInspectIsOpen()
    local fn=RPAL.IsODYNInspectOpen
    if type(fn)=="function" then
        local ok,shown=pcall(fn)
        if ok and shown then return true end
    end
    return false
end

function RPAL._hoverPerf.BlockingProfileUIIsOpen()
    return RPAL._hoverPerf.TRPUIIsOpen() or RPAL._hoverPerf.ODYNInspectIsOpen()
end

function RPAL.NotifyHoverLogTRPUIOpened()
    CancelHoverIntent()
    HidePrompt(true)
    HideQuickMenu(true)
    return true
end

function RPAL.NotifyHoverLogODYNInspectOpened()
    CancelHoverIntent()
    HidePrompt(true)
    HideQuickMenu(true)
    latchedGUID=nil
    HidePanel(true)
    return true
end

local function ShowQuickMenuForMouseover(guid,wowName)
    if not guid then guid,wowName=WorldMouseoverIdentity() end
    if not guid then HideQuickMenu(false);return false end
    currentHoverGUID,currentHoverName=guid,wowName
    if quickMenuGUID==guid and quickMenu and quickMenu:IsShown() and not(quickMenu.fadeOut and quickMenu.fadeOut:IsPlaying()) then
        quickMenuName=wowName or quickMenuName
        ApplyQuickMenuAppearance(quickMenuName,"mouseover")
        return true
    end
    HidePrompt(true)
    -- A visible Chat Log and a Quick Action menu may coexist. The log only
    -- switches identity if Chat Log is explicitly chosen from the new menu.
    quickMenuGUID,quickMenuName=guid,wowName
    local f=EnsureQuickMenu()
    local opening=not f:IsShown() or (f.fadeOut and f.fadeOut:IsPlaying())
    ApplyQuickMenuAppearance(wowName,"mouseover")
    PositionQuickMenuAtCursor()
    if opening then FadeIn(f) else f:Show() end
    -- The menu stays alive while the cursor remains on either the same 3D player
    -- or the menu itself. Once it leaves both, the 0.5-second fade grace applies.
    if ScheduleQuickMenuOutsideClose then ScheduleQuickMenuOutsideClose() end
    -- R is Whisper for other players and Session control for self, so all four
    -- contextual keys remain active in both menu variants.
    if RPAL.SetQuickActionContextBindingActive then pcall(RPAL.SetQuickActionContextBindingActive,true,true) end
    if not f._isSelf and RPAL.ProbeODYNUser then pcall(RPAL.ProbeODYNUser,wowName) end
    if C_Timer and C_Timer.After then
        local expected=guid
        C_Timer.After(.18,function()
            if quickMenuGUID==expected and quickMenu and quickMenu:IsShown() then ApplyQuickMenuAppearance(quickMenuName,"mouseover") end
        end)
    end
    return true
end

function RPAL._hoverPerf.PlayIdentityPromptIntro(f)
    if not f then return end
    f.fadeIn:Stop();f.fadeOut:Stop();f:SetAlpha(1);f:Show();UpdateHoverGuardState()
    f._ambientAge=0;f._ambientStep=0
    local rows={f.currentRow,f.actionRow}
    for _,row in ipairs(rows) do
        if row and row:IsShown() then row:SetAlpha(0) elseif row then row:SetAlpha(0) end
    end
    for i,tex in ipairs(f.fireflies or {}) do tex:SetAlpha((i%2==0) and .10 or .15) end
    f:SetScript("OnUpdate",function(self,elapsed)
        self._ambientStep=(self._ambientStep or 0)+(elapsed or 0)
        if self._ambientStep<.04 then return end
        local dt=self._ambientStep;self._ambientStep=0;self._ambientAge=(self._ambientAge or 0)+dt
        local t=self._ambientAge
        local durations={.17,.24}
        local delays={0,.065}
        local revealRows={self.currentRow,self.actionRow}
        for i,row in ipairs(revealRows) do
            if row and row:IsShown() then
                local p=(t-delays[i])/durations[i];if p<0 then p=0 elseif p>1 then p=1 end
                local ease=1-(1-p)*(1-p)*(1-p)
                local x=-26*(1-ease)
                row:ClearAllPoints();row:SetPoint("TOPLEFT",x,row._baseY or 0);row:SetPoint("TOPRIGHT",x,row._baseY or 0);row:SetAlpha(ease)
            end
        end
        for i,tex in ipairs(self.fireflies or {}) do
            local p=self._fireflyPoints and self._fireflyPoints[i]
            if p then
                local dx=math.sin(t*(.72+i*.09)+i)*5+math.cos(t*(.31+i*.03))*2
                local dy=math.cos(t*(.84+i*.07)+i*.4)*4+math.sin(t*(.39+i*.04))*2
                tex:ClearAllPoints();tex:SetPoint("TOPLEFT",self,"TOPLEFT",p[1]+dx,p[2]+dy)
                local entry=math.min(1,t/.24)
                local pulse=.06+(.5+.5*math.sin(t*(1.2+i*.13)+i))*((i%3==0) and .09 or .14)
                tex:SetAlpha(entry*pulse)
            end
        end
    end)
end

function RPAL._hoverPerf.StopIdentityPromptAmbient(f)
    if not f then return end
    f:SetScript("OnUpdate",nil)
    for _,row in ipairs({f.currentRow,f.actionRow}) do
        if row then
            row:ClearAllPoints();row:SetPoint("TOPLEFT",0,row._baseY or 0);row:SetPoint("TOPRIGHT",0,row._baseY or 0);row:SetAlpha(1)
        end
    end
end

local function ShowPromptForMouseover(guid,wowName)
    if RPAL._hoverPerf.BlockingProfileUIIsOpen() then HidePrompt(true);return false end
    if not guid then guid,wowName=WorldMouseoverIdentity()end
    if not guid then HidePrompt(false); return false end
    if quickMenu and quickMenu:IsShown() then
        if quickMenuGUID==guid then currentHoverName=wowName or currentHoverName;return true end
        HideQuickMenu(true)
    end
    if promptGUID==guid and prompt and prompt:IsShown() and not(prompt.fadeOut and prompt.fadeOut:IsPlaying()) then
        currentHoverName=wowName or currentHoverName
        return true
    end
    currentHoverGUID,currentHoverName=guid,wowName
    promptGUID=guid
    -- Keep the hover prompt local-only. Presence probing is deferred until the
    -- player actually opens Quick Actions, avoiding network/profile work while
    -- sweeping across a crowded roleplay scene.
    local f=EnsurePrompt()
    local opening=not f:IsShown()or(f.fadeOut and f.fadeOut:IsPlaying())
    ApplyPromptAppearance(wowName,"mouseover")
    PositionPromptAtCursor()
    if opening then
        if RPAL._hoverPerf.PlayIdentityPromptIntro then RPAL._hoverPerf.PlayIdentityPromptIntro(f) else FadeIn(f) end
    end
    return true
end

local function ShowFullForMouseover(animateLines,guid,wowName)
    -- Opening the Hover Log must never depend on chat history. The panel is also
    -- the entry point for Focus Messages, so an IC player with zero captured lines is
    -- still a completely valid target.
    if not guid then guid,wowName=WorldMouseoverIdentity()end
    if not guid then
        HidePanel(false)
        HidePrompt(false)
        return false
    end
    local f=EnsurePanel()
    local targetChanged=currentGUID~=guid
    local opening=not f:IsShown()or(f.fadeOut and f.fadeOut:IsPlaying())
    if not targetChanged and not opening then
        currentHoverGUID,currentHoverName=guid,wowName
        if prompt and prompt:IsShown() then HidePrompt(true)end
        return true
    end
    currentHoverGUID,currentHoverName=guid,wowName
    if targetChanged then RPAL._hoverPerf.ReleaseRenderCache(currentGUID) end
    currentGUID,currentUnitName=guid,wowName
    if prompt and prompt:IsShown() then HidePrompt(true) end
    if quickMenu and quickMenu:IsShown() then HideQuickMenu(true) end
    ApplyAppearance(wowName,"mouseover")
    UpdateFooter(guid)
    -- Always render, including the empty state. This also clears stale text from
    -- the previously-hovered player when the next player has no captured lines.
    RenderHistory(guid,opening or targetChanged)
    PositionAtCursor()
    if opening then FadeIn(f) end
    InstallWheelOverrides()
    SetFocusMessagesBindingActive(true)
    if (opening or targetChanged) and animateLines~=false and historyByGUID[guid] and #(historyByGUID[guid].messages or {})>0 then AnimateRecentLines(guid) end
    return true
end

local function CancelHoverIntent()
    hoverIntentGUID=nil
    hoverIntentName=nil
    hoverIntentElapsed=0
    if hoverIntentFrame then hoverIntentFrame:Hide() end
end

local function BeginHoverIntent(guid,wowName)
    if RPAL._hoverPerf.BlockingProfileUIIsOpen() then CancelHoverIntent();HidePrompt(true);HideQuickMenu(true);return false end
    if not guid then CancelHoverIntent();return false end
    if quickMenuGUID==guid and quickMenu and quickMenu:IsShown() then CancelHoverIntent();return true end
    if promptGUID==guid and prompt and prompt:IsShown() then
        CancelHoverIntent();return true
    end
    if hoverIntentGUID~=guid then
        hoverIntentGUID=guid
        hoverIntentName=wowName
        hoverIntentElapsed=0
    else
        hoverIntentName=wowName or hoverIntentName
    end
    if not hoverIntentFrame then
        hoverIntentFrame=CreateFrame("Frame")
        hoverIntentFrame:SetScript("OnUpdate",function(self,elapsed)
            if not hoverIntentGUID then self:Hide();return end
            if Settings().hoverLogEnabled==false then CancelHoverIntent();return end
            local liveGUID,liveName=WorldMouseoverIdentity()
            if not liveGUID or liveGUID~=hoverIntentGUID then CancelHoverIntent();return end
            if heldMode or latchedGUID==liveGUID or hotkeyDown or (quickMenu and quickMenu:IsShown()) then CancelHoverIntent();return end
            hoverIntentElapsed=hoverIntentElapsed+(elapsed or 0)
            if hoverIntentElapsed<HOVER_PROMPT_DELAY then return end
            local guid,name=hoverIntentGUID,liveName or hoverIntentName
            CancelHoverIntent()
            ShowPromptForMouseover(guid,name)
        end)
    end
    hoverIntentFrame:Show()
    return true
end

HandleMouseoverChanged=function(forceImmediate)
    if RPAL._hoverPerf.BlockingProfileUIIsOpen() then
        CancelHoverIntent();HidePrompt(true);HideQuickMenu(true)
        return false
    end
    local guid,wowName=WorldMouseoverIdentity()
    -- Quick Actions treats the same 3D player and the menu panel as one interaction
    -- surface. Leaving the model starts the grace timer; entering the panel cancels
    -- it. This prevents both instant dismissal and stuck menus.
    if quickMenu and quickMenu:IsShown() then
        if QuickMenuMouseOver() or quickMenuHovered or (guid and guid==quickMenuGUID) then
            quickMenuOutsideToken=quickMenuOutsideToken+1
        elseif ScheduleQuickMenuOutsideClose then
            ScheduleQuickMenuOutsideClose()
        end
        return true
    end
    -- A latched Chat Log remains open while the player explores another 3D
    -- player. That hovered player may independently earn a Quick Action prompt;
    -- the existing log switches only when Chat Log is chosen from that menu.
    if panel and panel:IsShown() and latchedGUID and currentGUID==latchedGUID then
        CancelMouseoutClose()
        if not guid then CancelHoverIntent();HidePrompt(false);currentHoverGUID,currentHoverName=nil,nil;return true end
        if currentHoverGUID and currentHoverGUID~=guid then CancelHoverIntent();HidePrompt(true) end
        currentHoverGUID,currentHoverName=guid,wowName
        if guid==latchedGUID then CancelHoverIntent();HidePrompt(false);return true end
        return BeginHoverIntent(guid,wowName)
    end
    if not guid then
        CancelHoverIntent()
        -- The open Chat Log and Quick Action Menu are both interactive cursor
        -- surfaces. A short grace lets the cursor leave the 3D model and enter
        -- either panel without the hover workflow collapsing underneath it.
        currentHoverGUID,currentHoverName=nil,nil
        HidePrompt(forceImmediate)
        if quickMenu and quickMenu:IsShown() and (QuickMenuMouseOver() or quickMenuHovered) then
            CancelMouseoutClose()
            return true
        end
        if panel and panel:IsShown() and (PanelMouseOver() or panelHovered) then
            CancelMouseoutClose()
            return true
        end
        if forceImmediate then
            latchedGUID=nil;pressStartedGUID=nil;HideQuickMenu(true);HidePanel(true)
        else
            ScheduleMouseoutClose(MOUSEOUT_GRACE)
        end
        return false
    end

    CancelMouseoutClose()
    panelHovered=false
    local changed=currentHoverGUID and currentHoverGUID~=guid
    if changed then
        CancelHoverIntent()
        latchedGUID=nil
        pressStartedGUID=nil
        HideQuickMenu(true)
        -- Reuse the already-visible surface while sweeping. Replaying a fade and
        -- rebuilding animation groups for every crossed model was the largest
        -- Hover Log cost in crowded scenes.
        if heldMode and panel and panel:IsShown() then
            StopLineAnimation(true)
        else
            -- A new player must earn the full 0.4s hover intent before any
            -- prompt is visible; never carry the previous player's prompt over.
            HidePrompt(true)
            HidePanel(true)
        end
    end
    currentHoverGUID,currentHoverName=guid,wowName

    if quickMenuGUID==guid and quickMenu and quickMenu:IsShown() then
        CancelHoverIntent();ApplyQuickMenuAppearance(wowName,"mouseover");return true
    end
    if heldMode then CancelHoverIntent();return ShowFullForMouseover(false,guid,wowName) end
    if latchedGUID==guid then CancelHoverIntent();return ShowFullForMouseover(true,guid,wowName) end
    -- During the short tap/hold decision window the press itself already opened
    -- the current target. Do not replace it with the prompt until the key is
    -- released or the hovered player changes.
    if hotkeyDown and pressStartedGUID==guid and panel and panel:IsShown() then
        CancelHoverIntent()
        return true
    end
    return BeginHoverIntent(guid,wowName)
end

local function IsModifierHotkey(key)
    key=tostring(key or ""):upper(); return key=="ALT" or key=="CTRL" or key=="SHIFT"
end
local function ModifierEventMatches(hotkey,key)
    if hotkey=="ALT" then return key=="LALT" or key=="RALT" end
    if hotkey=="CTRL" then return key=="LCTRL" or key=="RCTRL" end
    if hotkey=="SHIFT" then return key=="LSHIFT" or key=="RSHIFT" end
    return false
end
local function ModifierIsDown(hotkey)
    if hotkey=="ALT" then return IsAltKeyDown and IsAltKeyDown() end
    if hotkey=="CTRL" then return IsControlKeyDown and IsControlKeyDown() end
    if hotkey=="SHIFT" then return IsShiftKeyDown and IsShiftKeyDown() end
    return false
end

local function TapChatLogHotkey()
    if RPAL._odynKeyCaptureActive or Settings().hoverLogEnabled==false or not LocalCharacterIsIC() then return false end
    local guid,wowName=WorldMouseoverIdentity()
    if not guid and quickMenuGUID then guid,wowName=quickMenuGUID,quickMenuName end
    if not guid then return false end
    if latchedGUID==guid and panel and panel:IsShown() then
        latchedGUID=nil
        HidePanel(false)
        if IsWorldMouseoverPlayer() then ShowPromptForMouseover(guid,wowName) end
        return true,false
    end
    HideQuickMenu(true)
    latchedGUID=guid
    ShowFullForMouseover(true,guid,wowName)
    return true,true
end

local function ToggleQuickActionMenu()
    if RPAL._odynKeyCaptureActive or Settings().hoverLogEnabled==false then return false end
    local guid,wowName=WorldMouseoverIdentity()

    -- A real hovered player owns this input before the Chat Log close action.
    -- This removes the old conflict where the same Quick Action key closed the
    -- log instead of opening the hovered player's menu.
    if guid then
        if quickMenu and quickMenu:IsShown() then
            local same=guid==quickMenuGUID
            if same then
                local oldName=quickMenuName
                HideQuickMenu(false)
                if not RPAL._hoverPerf.BlockingProfileUIIsOpen() then ShowPromptForMouseover(guid,wowName or oldName) end
                return true,false
            end
            HideQuickMenu(true)
        end
        return ShowQuickMenuForMouseover(guid,wowName),true
    end

    -- With no hovered player, the shared key retains its original close-log
    -- behavior so a latched panel can always be dismissed from anywhere.
    if panel and panel:IsShown() then
        latchedGUID=nil
        HidePanel(false)
        HidePrompt(true)
        return true,false
    end
    if quickMenu and quickMenu:IsShown() then HideQuickMenu(false);return true,false end
    return false
end

function RPAL.QuickActionMenuHotkeyPressed()
    return ToggleQuickActionMenu()
end

function RPAL.QuickActionInspectPressed()
    if RPAL._odynKeyCaptureActive or not quickMenu or not quickMenu:IsShown() or not quickMenuName then return false end
    local name,guid=quickMenuName,quickMenuGUID
    local isSelf=IsSelfSpeaker and IsSelfSpeaker(name,guid) or false
    local hasODYN=isSelf or (RPAL.IsODYNUser and RPAL.IsODYNUser(name) or false)
    if not hasODYN then return false,"odyn_unavailable" end
    HideQuickMenu(true);HidePrompt(true)
    if isSelf and RPAL.OpenSelfPublicPreview then return RPAL.OpenSelfPublicPreview(false) end
    if RPAL.RequestSocialInspect then return RPAL.RequestSocialInspect(name,"profile") end
    return false,"inspect_unavailable"
end

function RPAL.QuickActionChatLogPressed()
    if RPAL._odynKeyCaptureActive or not quickMenu or not quickMenu:IsShown() or not quickMenuGUID then return false end
    local guid,name=quickMenuGUID,quickMenuName
    local isSelf=IsSelfSpeaker and IsSelfSpeaker(name,guid) or false
    HideQuickMenu(true);HidePrompt(true)
    if isSelf then
        if RPAL.ToggleTRPICStatus then return RPAL.ToggleTRPICStatus() end
        return false,"trp_status_unavailable"
    end
    local switching=panel and panel:IsShown() and currentGUID and currentGUID~=guid
    latchedGUID=guid
    if switching then StopLineAnimation(true) end
    return ShowFullForMouseover(true,guid,name)
end

function RPAL.QuickActionWhisperPressed()
    if RPAL._odynKeyCaptureActive or not quickMenu or not quickMenu:IsShown() or not quickMenuName then return false end
    local name,guid=quickMenuName,quickMenuGUID
    local isSelf=IsSelfSpeaker and IsSelfSpeaker(name,guid) or false
    HideQuickMenu(true);HidePrompt(true)
    if isSelf then
        if RPAL.IsSceneActive and RPAL.IsSceneActive() then
            if RPAL.RequestEndScene then return RPAL.RequestEndScene() end
            if RPAL.EndScene then return RPAL.EndScene() end
            return false,"session_stop_unavailable"
        end
        if RPAL.StartScene then return RPAL.StartScene() end
        return false,"session_start_unavailable"
    end
    if type(_G.ChatFrame_SendTell)=="function" then
        local ok,result=pcall(_G.ChatFrame_SendTell,name)
        if ok and result~=false then return true end
    end
    if type(_G.ChatFrame_OpenChat)=="function" then
        local ok,result=pcall(_G.ChatFrame_OpenChat,"/w "..tostring(name).." ",_G.DEFAULT_CHAT_FRAME)
        if ok and result~=false then return true end
    end
    return false,"chat_unavailable"
end

function RPAL.QuickActionTRPPressed()
    if RPAL._odynKeyCaptureActive or not quickMenu or not quickMenu:IsShown() or not quickMenuName then return false end
    local name=quickMenuName
    HideQuickMenu(true);HidePrompt(true)
    if RPAL.OpenTRPProfile then return RPAL.OpenTRPProfile(name) end
    return false,"trp_unavailable"
end

function RPAL.HoverLogHotkeyPressed()
    -- Kept as a compatibility hook for older macros. The user-facing
    -- hover flow now enters through Quick Action Menu and its Chat Log row.
    return TapChatLogHotkey()
end

function RPAL.IsHoverLogArmed()
    return (latchedGUID~=nil or heldMode) and true or false
end

function RPAL.SetHoverLogArmed(value, silent)
    if not value then
        latchedGUID=nil
        heldMode=false
        hotkeyDown=false
        pressStartedGUID=nil
        pressClosingLatch=false
        hotkeyPressToken=hotkeyPressToken+1
        HideQuickMenu(false);HidePanel(false)
        if LocalCharacterIsIC() and Settings().hoverLogEnabled~=false then ShowPromptForMouseover() else HidePrompt(false) end
        return true,false
    end
    if not LocalCharacterIsIC() or Settings().hoverLogEnabled==false then
        HideQuickMenu(false);HidePanel(false);HidePrompt(false)
        return false,"not_ic"
    end
    return TapChatLogHotkey()
end

function RPAL.ToggleHoverLogArmed()
    return TapChatLogHotkey()
end

function RPAL.RefreshHoverLogState()
    InvalidateICCache()
    if Settings().hoverLogEnabled==false then
        latchedGUID=nil;heldMode=false;hotkeyDown=false;pressStartedGUID=nil
        HidePrompt(false);HideQuickMenu(false);HidePanel(false)
        if lendEarCount>0 and RPAL.ClearLendEar then RPAL.ClearLendEar() end
        return false
    end
    if not LocalCharacterIsIC() then
        -- Chat Log / Focus Messages remain IC-only. Self Quick Actions are the
        -- sole OOC exception so Go IC is reachable without opening Settings/TRP.
        latchedGUID=nil;heldMode=false;hotkeyDown=false;pressStartedGUID=nil
        HidePanel(false)
        if lendEarCount>0 and RPAL.ClearLendEar then RPAL.ClearLendEar() end
        if WorldMouseoverIdentity() then return HandleMouseoverChanged(false) end
        HidePrompt(false);HideQuickMenu(false)
        return false
    end
    return HandleMouseoverChanged(false)
end

function RPAL.NotifyHoverLogTRPStatusChanged()
    InvalidateICCache()
    return RPAL.RefreshHoverLogState()
end

function RPAL.RefreshHoverQuickActionMenu()
    if quickMenu and quickMenu:IsShown() and quickMenuName then ApplyQuickMenuAppearance(quickMenuName,"mouseover") end
end

function RPAL.RefreshHoverLogAppearance()
    if panel and panel:IsShown() and currentUnitName then ApplyAppearance(currentUnitName,"mouseover");UpdateFooter(currentGUID);RenderHistory(currentGUID,false);PositionAtCursor() end
    if prompt and prompt:IsShown() and currentHoverName then ApplyPromptAppearance(currentHoverName,"mouseover");PositionPromptAtCursor() end
    if quickMenu and quickMenu:IsShown() and quickMenuName then ApplyQuickMenuAppearance(quickMenuName,"mouseover");PositionQuickMenuAtCursor() end
end
function RPAL.RefreshHoverLogPrompt() RPAL.RefreshHoverLogAppearance() end
function RPAL.ClearHoverLogHistory() wipe(historyByGUID);wipe(guidByName);wipe(RPAL._hoverPerf.nameOrder);wipe(RPAL._hoverPerf.nameKnown);RPAL._hoverPerf.nameHead,RPAL._hoverPerf.nameTail=1,0;ClearIdentityCache();latchedGUID=nil;currentHoverGUID=nil;HidePrompt(true);HideQuickMenu(true);HidePanel(true) end
function RPAL.RefreshHoverLogIdentity() selfNameCache=nil;selfNameCacheAt=-100;mentionTokensCache=nil;mentionTokensCacheAt=-100;mentionScanText=nil;mentionScanSpans=nil;mentionScanAt=-100;ClearIdentityCache();return SelfTRPName() end

LendEarSpeaker = function(author,guid)
    guid=ResolveGUID(author,guid); if guid and lendEarGUIDs[guid] then return true end
    local n=NormalizeName(author); local short=NormalizeName(ShortName(author)); return lendEarNames[n] or lendEarNames[short] or false
end

function RPAL.GetLendEarCount() return lendEarCount end
function RPAL.IsLendingEarToGUID(guid) return guid and lendEarGUIDs[guid] and true or false end
function RPAL.GetLendEarNames()
    local out={}
    for guid,label in pairs(lendEarLabels) do
        if lendEarGUIDs[guid] and label and label~="" then out[#out+1]=label end
    end
    table.sort(out,function(a,b)return tostring(a):lower()<tostring(b):lower() end)
    return out
end
local function UpdateLendEarUI()
    if panel and panel:IsShown() then UpdateFooter(currentGUID) end
    if RPAL.UpdateLendEarIndicator then pcall(RPAL.UpdateLendEarIndicator) end
end
function RPAL.ClearLendEar()
    local hadAny=lendEarCount>0
    wipe(lendEarGUIDs); wipe(lendEarNames); wipe(lendEarLabels); lendEarCount=0
    UpdateLendEarUI()
    if hadAny then RefreshLendEarChat() end
    -- RefreshLendEarChat is synchronous; once focus mode is off the cached
    -- base lines are no longer needed and can be released immediately.
    ClearLendEarBaseCache()
    return true
end

local function ToggleLendEarIdentity(guid,wowName)
    if RPAL._odynKeyCaptureActive or Settings().hoverLogEnabled==false or not LocalCharacterIsIC() then return false end
    guid=SafeString(guid); wowName=SafeString(wowName)
    if not guid or not wowName then return false end
    RememberNameGUID(wowName,guid)
    local fullKey=NormalizeName(wowName); local shortKey=NormalizeName(ShortName(wowName))
    if lendEarGUIDs[guid] then
        lendEarGUIDs[guid]=nil
        lendEarNames[fullKey]=nil; lendEarNames[shortKey]=nil
        lendEarLabels[guid]=nil
        lendEarCount=math.max(0,lendEarCount-1)
    else
        lendEarGUIDs[guid]=true
        lendEarNames[fullKey]=true; lendEarNames[shortKey]=true
        local display=select(1,RemoteIdentity(wowName,nil))
        lendEarLabels[guid]=(display and display~="" and display) or ShortName(wowName)
        lendEarCount=lendEarCount+1
    end
    UpdateLendEarUI()
    -- Re-evaluate the already-visible chat immediately. This restores a newly
    -- prioritised speaker, dims a removed speaker while others remain tagged,
    -- or restores everybody and fully ends focus mode when the count hits zero.
    RefreshLendEarChat()
    if lendEarCount<=0 then ClearLendEarBaseCache() end
    return true,lendEarGUIDs[guid] and true or false
end

function RPAL.ToggleLendEarForCurrentPanel()
    if not panel or not panel:IsShown() or not currentGUID or not currentUnitName then return false end
    return ToggleLendEarIdentity(currentGUID,currentUnitName)
end

function RPAL.ToggleLendEarForMouseover()
    -- Keybinds act on the player already shown in the panel first. This remains
    -- reliable after the cursor has moved onto the panel and the WoW mouseover
    -- unit token no longer exists.
    if panel and panel:IsShown() and currentGUID and currentUnitName then
        return ToggleLendEarIdentity(currentGUID,currentUnitName)
    end
    local guid,wowName=WorldMouseoverIdentity()
    if not guid then return false end
    return ToggleLendEarIdentity(guid,wowName)
end

local mouseoverResolvePending=false
local function QueueMouseoverResolve(immediate)
    if immediate or not C_Timer or not C_Timer.After then
        HandleMouseoverChanged(false)
        return
    end
    if mouseoverResolvePending then return end
    mouseoverResolvePending=true
    -- UPDATE_MOUSEOVER_UNIT can arrive on the same frame that the secure unit
    -- token is being swapped. Keep exactly one next-frame resolver alive; rapid
    -- sweeps no longer allocate a cancelled timer for every intermediate unit.
    C_Timer.After(0,function()
        mouseoverResolvePending=false
        HandleMouseoverChanged(false)
    end)
end

-- UPDATE_MOUSEOVER_UNIT is not guaranteed to deliver a final clean mouseout on
-- every protected-frame transition. A tiny throttled guard runs only while one
-- of Hover Log's surfaces is visible and validates just the live GUID. It both
-- closes orphaned prompts and avoids any per-frame TRP/profile work.
local hoverGuard=CreateFrame("Frame")
local hoverGuardElapsed=0
hoverGuard:Hide()
UpdateHoverGuardState=function()
    local promptShown=prompt and prompt:IsShown()
    local panelShown=panel and panel:IsShown()
    local quickShown=quickMenu and quickMenu:IsShown()
    if promptShown or panelShown or quickShown then hoverGuardElapsed=0;hoverGuard:Show()
    else hoverGuard:Hide()end
end
hoverGuard:SetScript("OnUpdate",function(self,elapsed)
    hoverGuardElapsed=hoverGuardElapsed+(elapsed or 0)
    if hoverGuardElapsed<HOVER_GUARD_INTERVAL then return end
    hoverGuardElapsed=0
    local quickActive=quickMenu and quickMenu:IsShown() and quickMenuGUID~=nil
    local panelActive=panel and panel:IsShown()and currentGUID~=nil
    local promptActive=prompt and prompt:IsShown() and promptGUID~=nil
    if RPAL._hoverPerf.BlockingProfileUIIsOpen() then CancelHoverIntent();HidePrompt(true);HideQuickMenu(true);if panelActive then latchedGUID=nil;HidePanel(true);panelActive=false end;promptActive=false;quickActive=false end
    if quickActive then return end -- independent 0.5s outside timer owns this surface
    if panelActive and latchedGUID and currentGUID==latchedGUID and not promptActive then return end
    if panelActive and(panelHovered or PanelMouseOver())then return end
    local expectedGUID=quickActive and quickMenuGUID or (promptActive and promptGUID) or (panelActive and currentGUID or currentHoverGUID)
    local liveGUID
    if Settings().hoverLogEnabled~=false then liveGUID=select(1,WorldMouseoverIdentity()) end
    if not expectedGUID or not liveGUID or liveGUID~=expectedGUID then QueueMouseoverResolve(true)end
end)

local event=CreateFrame("Frame")
event:RegisterEvent("PLAYER_LOGIN"); event:RegisterEvent("PLAYER_ENTERING_WORLD"); event:RegisterEvent("UPDATE_MOUSEOVER_UNIT"); event:RegisterEvent("MODIFIER_STATE_CHANGED")
event:RegisterEvent("CHAT_MSG_SAY"); event:RegisterEvent("CHAT_MSG_EMOTE"); event:RegisterEvent("CHAT_MSG_TEXT_EMOTE")
event:SetScript("OnEvent",function(_,eventName,...)
    if eventName=="PLAYER_LOGIN" or eventName=="PLAYER_ENTERING_WORLD" then
        selfGUID=SafeUnitGUID("player")
        InvalidateICCache()
        RPAL.RefreshHoverLogIdentity()
        latchedGUID=nil; heldMode=false; hotkeyDown=false; pressStartedGUID=nil; pressClosingLatch=false; panelCustomPosition=false
        CancelHoverIntent();HidePrompt(true);HideQuickMenu(true);HidePanel(true)
        if RPAL.UpdateLendEarIndicator then pcall(RPAL.UpdateLendEarIndicator) end
        return
    end
    if eventName=="UPDATE_MOUSEOVER_UNIT" then QueueMouseoverResolve(false); return end
    if eventName=="MODIFIER_STATE_CHANGED" then
        if RPAL._odynKeyCaptureActive then return end
        local key,state=...
        local hotkey=QuickActionHotkey()
        if not IsModifierHotkey(hotkey) or not ModifierEventMatches(hotkey,key) then return end
        state=tonumber(state) or 0
        if state==1 then
            if Settings().hoverLogEnabled==false then hotkeyDown=false;return end
            if hotkeyDown then return end
            hotkeyDown=true
            hotkeyPressToken=hotkeyPressToken+1
            ToggleQuickActionMenu()
        else
            hotkeyDown=false
            hotkeyPressToken=hotkeyPressToken+1
        end
        return
    end

    local message,author=...; local guid=ExtractPlayerGUID(...); local targetName=SafeString(select(5,...)); message,author=SafeString(message),SafeString(author); if not message or not author then return end
    -- Disabling Hover Log disables its capture/notification pipeline too, so
    -- the feature has effectively zero chat-processing overhead when off.
    if Settings().hoverLogEnabled==false then return end
    local resolvedGUID; if LocalCharacterIsIC() then resolvedGUID=AddHistory(eventName,message,author,guid) end
    local isSelf=IsSelfSpeaker(author,guid)
    local directed=IsDirectedInteraction(eventName,author,guid,targetName,message)
    local mentioned=ContainsMention(message)
    -- Directed interactions take precedence. Blizzard's rendered "/nod at X"
    -- line normally contains X's name, so checking mention first caused those
    -- emotes to play the mention asset and made Emoted At appear intermittent.
    if directed then
        -- Never fall through to Mention for a directed emote, even while the
        -- short directed-alert cooldown is active.
        if Now()-lastTargetSoundAt>=.75 then
            lastTargetSoundAt=Now(); PlaySmallSound("target")
        end
    elseif not isSelf and mentioned and Now()-lastMentionSoundAt>=1.15 then
        lastMentionSoundAt=Now(); PlaySmallSound("mention")
    end
    if lendEarCount>0 then QueueLendEarLineRefresh(select(11,...)) end
    if panel and panel:IsShown() and currentGUID and resolvedGUID==currentGUID then
        StopLineAnimation(true); RenderHistory(currentGUID,false); UpdateFooter(currentGUID)
    elseif resolvedGUID and IsWorldMouseoverPlayer() and resolvedGUID==SafeUnitGUID("mouseover") then
        -- If this is the first line we have ever seen from the person currently
        -- under the cursor, the small Press [key] prompt can appear immediately.
        QueueMouseoverResolve(false)
    end
end)

local function RPChatFilter(_,eventName,message,author,...)
    if Settings().hoverLogEnabled==false then return false,message,author,... end
    local text=SafeString(message)
    if not text then return false,message,author,... end
    -- Keep the event payload otherwise untouched. Complete-line Focus Messages
    -- emphasis is applied after Blizzard has rendered the sender hyperlink and
    -- "says:" prefix, which is what lets ODYN dim/restore the whole line.
    if ContainsMention(text) then text=HighlightMention(text,nil) end
    return false,text,author,...
end

if ChatFrame_AddMessageEventFilter then
    ChatFrame_AddMessageEventFilter("CHAT_MSG_SAY",RPChatFilter)
    ChatFrame_AddMessageEventFilter("CHAT_MSG_EMOTE",RPChatFilter)
    ChatFrame_AddMessageEventFilter("CHAT_MSG_TEXT_EMOTE",RPChatFilter)
    ChatFrame_AddMessageEventFilter("CHAT_MSG_YELL",RPChatFilter)
end
