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

-- RPAL HUD companion + optional Total RP 3 coexistence ----------------------
--
-- v2.19.006 keeps RPAL statistics out of TRP's
-- "Other information (OOC)" field. RPAL hooks both Blizzard's GameTooltip and
-- Total RP 3's normal TRP3_CharacterTooltip, so the companion block remains
-- visible even when TRP replaces the stock hover tooltip (no Alt key required).
-- Remote RPAL clients exchange only the four tiny values needed by this HUD
-- block over a dedicated addon-message prefix.
--
-- A viewer without RPAL cannot execute a new RPAL tooltip renderer. For those
-- TRP-only viewers, RPAL publishes the same summary as a dedicated custom
-- Additional Information entry in the TRP profile. Stock TRP intentionally does
-- not render custom MI fields in its hover tooltip, but the entry is visible in
-- the full TRP profile without touching Currently/OOC/client identity fields.

local COMPANION_LABEL = "ODYN - RP Companion"
local LEGACY_COMPANION_LABEL = "RPAL - RP Companion"
local ACTIVITY_LABEL = "ODYN Activity"
local TIP_PREFIX = "RPAL_TTIP"
local TIP_PROTOCOL = 1
local TIP_CACHE_FRESH_TTL = 90
local TIP_CACHE_STALE_TTL = 900
local TIP_CACHE_LIMIT = 64
local TIP_QUERY_COOLDOWN = 8

local refreshQueued = false
local notifyingTRP = false
local tooltipHooked = false
local tooltipHookMode = "none"
local tooltipProcessorRegistered = false
local trpTooltipHooked = false
local trpTooltipHookMode = "waiting_for_TRP3_CharacterTooltip"
local trpProfileAdvertiserResult = "not_run"
local tipCache = {}
local tipQueries = {}
local lastCleanupResult = "not_run"
local lastTRPRender = { result = "not_run" }

local function PruneTipCache()
    local now = (GetTime and GetTime()) or 0
    local count, oldestKey, oldestAt = 0, nil, math.huge
    for key, entry in pairs(tipCache) do
        local receivedAt = tonumber(entry and entry.receivedAt) or 0
        if now - receivedAt > TIP_CACHE_STALE_TTL then
            tipCache[key] = nil
        else
            count = count + 1
            if receivedAt < oldestAt then oldestKey, oldestAt = key, receivedAt end
        end
    end
    while count >= TIP_CACHE_LIMIT and oldestKey do
        tipCache[oldestKey] = nil
        count = count - 1
        oldestKey, oldestAt = nil, math.huge
        for key, entry in pairs(tipCache) do
            local receivedAt = tonumber(entry and entry.receivedAt) or 0
            if receivedAt < oldestAt then oldestKey, oldestAt = key, receivedAt end
        end
    end
end

local function Trim(value)
    value = tostring(value or "")
    return value:gsub("^%s+", ""):gsub("%s+$", "")
end

local function TrimRight(value)
    value = tostring(value or "")
    return value:gsub("%s+$", "")
end

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

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

local function NamesEquivalent(a, b)
    local na, nb = NormalizeName(a), NormalizeName(b)
    if na == "" or nb == "" then return false end
    if na == nb then return true end
    -- If both values are realm-qualified, do not collapse two different
    -- characters that happen to share the same name across realms.
    if na:find("-", 1, true) and nb:find("-", 1, true) then return false end
    return ShortName(na) == ShortName(nb)
end

local function PlayerFullName()
    local name, realm
    if UnitFullName then name, realm = UnitFullName("player") end
    name = name or (UnitName and UnitName("player")) or ""
    realm = realm or (GetNormalizedRealmName and GetNormalizedRealmName()) or ""
    if realm ~= "" and not tostring(name):find("-", 1, true) then
        return tostring(name) .. "-" .. tostring(realm)
    end
    return tostring(name)
end

local function CompanionIsHidden()
    local privacy = type(RPAL.GetProfilePrivacy) == "function" and RPAL.GetProfilePrivacy() or nil
    if type(privacy) ~= "table" then return false, "visible" end
    if privacy.hidePublicProfile then return true, "public_profile_hidden" end
    if privacy.hideTRPCompanion then return true, "companion_hidden" end
    return false, "visible"
end

local function SessionCount()
    if type(RPAL.GetScenes) ~= "function" then return 0 end
    local ok, scenes = pcall(RPAL.GetScenes)
    if not ok or type(scenes) ~= "table" then return 0 end
    return #scenes
end

local function CurrentLevel()
    if type(RPAL.GetCurrentRenownLevel) ~= "function" then return 1 end
    local ok, level = pcall(RPAL.GetCurrentRenownLevel)
    if not ok then return 1 end
    return math.max(1, math.floor(tonumber(level) or 1))
end

local function CurrentStatistics()
    if type(RPAL.GetStatistics) ~= "function" then return 0, 0 end
    local ok, statistics = pcall(RPAL.GetStatistics)
    if not ok or type(statistics) ~= "table" then return 0, 0 end
    return math.max(0, math.floor(tonumber(statistics.charactersMet) or 0)),
           math.max(0, math.floor(tonumber(statistics.score) or 0))
end

local function LocalCompanionData()
    local charactersMet, renown = CurrentStatistics()
    return {
        level = CurrentLevel(),
        sessions = SessionCount(),
        charactersMet = charactersMet,
        renown = renown,
    }
end

function RPAL.GetTRPCompanionSummary()
    local data = LocalCompanionData()
    return string.format(
        "Level %d • Sessions %d • Characters Met %d • Renown %d",
        data.level, data.sessions, data.charactersMet, data.renown
    )
end

function RPAL.GetTRPCompanionBlock()
    return COMPANION_LABEL .. "\n" .. RPAL.GetTRPCompanionSummary()
end

-- Legacy cleanup --------------------------------------------------------------
-- v2.19.001/.002 appended the companion block to character.CO.  Remove only the
-- exact generated suffix and leave all player-authored OOC text untouched.
local function StripCompanionSuffix(value)
    local text = tostring(value or ""):gsub("\r\n", "\n"):gsub("\r", "\n")
    local labels = { COMPANION_LABEL, LEGACY_COMPANION_LABEL }
    for _, label in ipairs(labels) do
        local marker = "\n\n" .. label .. "\n"
        local searchFrom, lastStart = 1, nil
        while true do
            local found = text:find(marker, searchFrom, true)
            if not found then break end
            lastStart = found
            searchFrom = found + 1
        end
        if lastStart then
            local tail = text:sub(lastStart + #marker)
            if tail:match("^Level%s+%d+%s+•%s+Sessions%s+%d+%s+•%s+Characters%s+Met%s+%d+%s+•%s+Renown%s+%d+%s*$") then
                return TrimRight(text:sub(1, lastStart - 1)), true
            end
        end
        local prefix = label .. "\n"
        if text:sub(1, #prefix) == prefix then
            local tail = text:sub(#prefix + 1)
            if tail:match("^Level%s+%d+%s+•%s+Sessions%s+%d+%s+•%s+Characters%s+Met%s+%d+%s+•%s+Renown%s+%d+%s*$") then
                return "", true
            end
        end
    end
    return TrimRight(text), false
end

local function GetTRPCharacterTable()
    local api = _G.TRP3_API
    local profile = type(api) == "table" and api.profile or nil
    if type(profile) ~= "table" or type(profile.getData) ~= "function" then
        return nil, "trp_profile_api_unavailable"
    end
    local ok, character = pcall(profile.getData, "player/character")
    if not ok or type(character) ~= "table" then
        return nil, "trp_character_unavailable"
    end
    return character
end

local function GetActiveProfileID()
    if type(RPAL.GetActiveTRPProfileID) == "function" then
        local ok, id = pcall(RPAL.GetActiveTRPProfileID)
        if ok and id and tostring(id) ~= "" then return tostring(id) end
    end
    return tostring(RPAL._activeTRPProfileScopeID or "unknown")
end

local function GetBridgeState()
    local scope
    if type(RPAL.GetCurrentTRPProfileScope) == "function" then
        local ok, value = pcall(RPAL.GetCurrentTRPProfileScope)
        if ok and type(value) == "table" then scope = value end
    end
    if type(scope) ~= "table" and type(_G.RPAL_CHAR_DB) == "table" then
        scope = _G.RPAL_CHAR_DB
    end
    if type(scope) ~= "table" then return nil end
    scope.trpCompanionMirror = type(scope.trpCompanionMirror) == "table" and scope.trpCompanionMirror or {}
    return scope.trpCompanionMirror
end

local function CleanupLegacyVA()
    local lib = _G.msp
    if type(lib) ~= "table" or type(lib.my) ~= "table" then return false end
    local current = tostring(lib.my.VA or "")
    if current == "" or (not current:find(COMPANION_LABEL, 1, true) and not current:find(LEGACY_COMPANION_LABEL, 1, true)) then return false end

    local clean = {}
    for entry in current:gmatch("[^;]+") do
        local addonName = Trim(entry:match("^([^/]+)/") or entry)
        if addonName ~= COMPANION_LABEL and addonName ~= LEGACY_COMPANION_LABEL then clean[#clean + 1] = entry end
    end
    local desired = table.concat(clean, ";")
    if desired == current then return false end

    lib.my.VA = desired
    if type(lib.Update) == "function" then pcall(lib.Update, lib) end
    return true
end

local function NotifyTRPDataChanged(dataType)
    if notifyingTRP then return false, "already_notifying" end
    local addon = _G.TRP3_Addon
    local api = _G.TRP3_API
    local events = type(addon) == "table" and addon.Events or nil
    local playerID = type(api) == "table" and type(api.globals) == "table" and api.globals.player_id or nil
    local eventName = type(events) == "table" and events.REGISTER_DATA_UPDATED or nil

    if type(addon) == "table" and type(addon.TriggerEvent) == "function" and eventName and playerID then
        notifyingTRP = true
        local ok, err = pcall(addon.TriggerEvent, addon, eventName, playerID, true, dataType or "character")
        notifyingTRP = false
        if ok then return true, "REGISTER_DATA_UPDATED:" .. tostring(dataType or "character") end
        return false, tostring(err or "trp_event_failed")
    end
    return false, "trp_event_api_unavailable"
end

local function NotifyTRPCharacterChanged()
    return NotifyTRPDataChanged("character")
end

local function SyncMSPCleanup(value)
    local lib = _G.msp
    if type(lib) ~= "table" or type(lib.my) ~= "table" or type(lib.Update) ~= "function" then
        return false, "msp_unavailable"
    end
    lib.my.CO = value
    local ok, err = pcall(lib.Update, lib)
    if not ok then return false, tostring(err or "msp_update_failed") end
    return true, "msp_update"
end

local function CleanupLegacyOOCMirror()
    CleanupLegacyVA()

    local character, reason = GetTRPCharacterTable()
    if not character then
        lastCleanupResult = tostring(reason)
        return false, reason
    end

    local current = tostring(character.CO or "")
    local base, hadBlock = StripCompanionSuffix(current)
    local state = GetBridgeState()

    if state then
        state.profileID = GetActiveProfileID()
        state.baseCO = base
        state.preview = nil
        state.lastSummary = nil
        state.hudMode = true
        state.legacyMirrorRemoved = hadBlock and true or (state.legacyMirrorRemoved and true or false)
    end

    if not hadBlock then
        lastCleanupResult = "no_legacy_ooc_block"
        if state then state.lastResult = "HUD mode; OOC untouched" end
        return false, "no_legacy_ooc_block"
    end

    character.CO = base
    local notified, notifyReason = NotifyTRPCharacterChanged()
    if not notified then
        local fallbackOK, fallbackReason = SyncMSPCleanup(base)
        if fallbackOK then
            notified, notifyReason = true, fallbackReason
        else
            notifyReason = tostring(notifyReason) .. "; " .. tostring(fallbackReason)
        end
    end

    lastCleanupResult = "removed_legacy_ooc_block via " .. tostring(notifyReason)
    if state then
        state.lastPublished = nil
        state.lastResult = lastCleanupResult
        state.lastPublishedAt = time and time() or 0
    end
    return true, lastCleanupResult
end

-- TRP-only profile advertiser -------------------------------------------------
-- Stock TRP does not expose an arbitrary sender-defined hover-tooltip field.
-- The clean, non-destructive compatibility surface is a Custom Additional
-- Information entry. It travels with the TRP profile and is visible to TRP-only
-- viewers when they open the profile, while leaving CU/CO/VA untouched.
local PROFILE_AD_ICON = "INV_Misc_TabardPVP_02" -- Native WoW crest-style icon: remains visible to TRP-only clients without ODYN assets.

local function GetTRPCharacteristicsTable()
    local api = _G.TRP3_API
    local profile = type(api) == "table" and api.profile or nil
    if type(profile) ~= "table" or type(profile.getData) ~= "function" then
        return nil, "trp_profile_api_unavailable"
    end
    local ok, characteristics = pcall(profile.getData, "player/characteristics")
    if not ok or type(characteristics) ~= "table" then
        return nil, "trp_characteristics_unavailable"
    end
    return characteristics
end

local function FindTRPProfileAdvertiser(mi, wantedLabel)
    if type(mi) ~= "table" then return nil end
    wantedLabel = tostring(wantedLabel or COMPANION_LABEL)
    for index, entry in ipairs(mi) do
        if type(entry) == "table" and tonumber(entry.ID or 1) == 1 then
            local name = tostring(entry.NA or "")
            if wantedLabel == COMPANION_LABEL then
                if name == COMPANION_LABEL or name == LEGACY_COMPANION_LABEL then return index, entry end
            elseif name == wantedLabel then
                return index, entry
            end
        end
    end
    return nil
end

local function RemoveTRPProfileAdvertiser(characteristics)
    local mi = type(characteristics) == "table" and characteristics.MI or nil
    if type(mi) ~= "table" then return false end
    local changed = false
    for index = #mi, 1, -1 do
        local entry = mi[index]
        if type(entry) == "table" and tonumber(entry.ID or 1) == 1 then
            local name = tostring(entry.NA or "")
            if name == COMPANION_LABEL or name == LEGACY_COMPANION_LABEL or name == ACTIVITY_LABEL then
                table.remove(mi, index)
                changed = true
            end
        end
    end
    return changed
end

local function SyncTRPProfileAdvertiser()
    local characteristics, reason = GetTRPCharacteristicsTable()
    if not characteristics then
        trpProfileAdvertiserResult = tostring(reason)
        return false, reason
    end

    local hidden, hiddenReason = CompanionIsHidden()
    local privacy = type(RPAL.GetProfilePrivacy) == "function" and RPAL.GetProfilePrivacy() or nil
    local additionalInfoEnabled = type(privacy) == "table" and privacy.showTRPAdditionalInfo == true
    if hidden or not additionalInfoEnabled then
        local removalReason = hidden and hiddenReason or "additional_info_opt_out"
        if RemoveTRPProfileAdvertiser(characteristics) then
            local ok, eventReason = NotifyTRPDataChanged("characteristics")
            trpProfileAdvertiserResult = "removed_hidden:" .. tostring(removalReason) .. "; " .. tostring(eventReason)
            return true, trpProfileAdvertiserResult
        end
        trpProfileAdvertiserResult = "hidden_no_profile_card:" .. tostring(removalReason)
        return false, trpProfileAdvertiserResult
    end

    characteristics.MI = type(characteristics.MI) == "table" and characteristics.MI or {}
    local data = LocalCompanionData()
    -- TRP's Additional Information value column is narrow and ellipsizes long
    -- strings. Publish two compact native rows so TRP-only viewers can read all
    -- four stats without truncation while the ODYN name remains prominent.
    local companionValue = string.format("Level %d • Renown %d", data.level, data.renown)
    local activityValue = string.format("Sessions %d • Met %d", data.sessions, data.charactersMet)
    local changed = false

    local _, companion = FindTRPProfileAdvertiser(characteristics.MI, COMPANION_LABEL)
    if not companion then
        companion = { ID = 1, NA = COMPANION_LABEL, VA = companionValue, IC = PROFILE_AD_ICON }
        table.insert(characteristics.MI, companion)
        changed = true
    else
        if tonumber(companion.ID) ~= 1 then companion.ID = 1; changed = true end
        if tostring(companion.NA or "") ~= COMPANION_LABEL then companion.NA = COMPANION_LABEL; changed = true end
        if tostring(companion.VA or "") ~= companionValue then companion.VA = companionValue; changed = true end
        if tostring(companion.IC or "") ~= PROFILE_AD_ICON then companion.IC = PROFILE_AD_ICON; changed = true end
    end

    local _, activity = FindTRPProfileAdvertiser(characteristics.MI, ACTIVITY_LABEL)
    if not activity then
        activity = { ID = 1, NA = ACTIVITY_LABEL, VA = activityValue, IC = "INV_Misc_Note_01" }
        table.insert(characteristics.MI, activity)
        changed = true
    else
        if tonumber(activity.ID) ~= 1 then activity.ID = 1; changed = true end
        if tostring(activity.NA or "") ~= ACTIVITY_LABEL then activity.NA = ACTIVITY_LABEL; changed = true end
        if tostring(activity.VA or "") ~= activityValue then activity.VA = activityValue; changed = true end
        if tostring(activity.IC or "") ~= "INV_Misc_Note_01" then activity.IC = "INV_Misc_Note_01"; changed = true end
    end

    if changed then
        local ok, eventReason = NotifyTRPDataChanged("characteristics")
        trpProfileAdvertiserResult = "published_compact_rows; " .. tostring(eventReason)
        return true, trpProfileAdvertiserResult
    end
    trpProfileAdvertiserResult = "already_current"
    return false, trpProfileAdvertiserResult
end

local function CleanupTRPProfileAdvertiserForLogout()
    local characteristics = GetTRPCharacteristicsTable()
    if characteristics then
        -- Keep the advertiser runtime-only so uninstalling RPAL after a normal
        -- logout/reload does not leave generated profile data behind.
        RemoveTRPProfileAdvertiser(characteristics)
    end
end

-- Tooltip rendering -----------------------------------------------------------
--
-- RPAL owns its HUD block, but there are two physical tooltip routes:
-- Blizzard GameTooltip and TRP3_CharacterTooltip. TRP normally hides/replaces
-- GameTooltip, so both are hooked. The TRP frame exposes its current character
-- in .target/.targetType; we use that instead of altering TRP profile text.
local function FindCompanionLine(tooltip)
    if not tooltip or not tooltip.GetName or not tooltip.NumLines then return nil end
    local tooltipName = tooltip:GetName()
    if not tooltipName or tooltipName == "" then return nil end
    for index = 1, tooltip:NumLines() do
        local left = _G[tooltipName .. "TextLeft" .. index]
        if left and left.GetText then
            local value = tostring(left:GetText() or "")
            if value == COMPANION_LABEL or value == LEGACY_COMPANION_LABEL then return index end
        end
    end
    return nil
end

local function SetTooltipLineText(tooltip, index, leftText, rightText)
    if not tooltip or not tooltip.GetName then return false end
    local tooltipName = tooltip:GetName()
    if not tooltipName or tooltipName == "" then return false end
    local left = _G[tooltipName .. "TextLeft" .. tostring(index)]
    local right = _G[tooltipName .. "TextRight" .. tostring(index)]
    if left and left.SetText then left:SetText(leftText or "") end
    if right and right.SetText then right:SetText(rightText or "") end
    return left ~= nil or right ~= nil
end

local function RenderCompanionBlock(tooltip, data, forceLayout)
    if not tooltip or type(data) ~= "table" then return false end
    if not tooltip.AddLine or not tooltip.AddDoubleLine then return false end

    local level = math.max(1, math.floor(tonumber(data.level) or 1))
    local sessions = math.max(0, math.floor(tonumber(data.sessions) or 0))
    local charactersMet = math.max(0, math.floor(tonumber(data.charactersMet) or 0))
    local renown = math.max(0, math.floor(tonumber(data.renown) or 0))

    local headerLine = FindCompanionLine(tooltip)
    if headerLine then
        SetTooltipLineText(tooltip, headerLine, COMPANION_LABEL, "ODYN")
        SetTooltipLineText(tooltip, headerLine + 1, "Level " .. level, "Sessions " .. sessions)
        SetTooltipLineText(tooltip, headerLine + 2, "Characters Met " .. charactersMet, "Renown " .. renown)
    else
        tooltip:AddLine(" ")
        tooltip:AddDoubleLine(COMPANION_LABEL, "ODYN", 1.00, 0.79, 0.22, 0.62, 0.67, 0.74)
        tooltip:AddDoubleLine("Level " .. level, "Sessions " .. sessions, 0.88, 0.88, 0.88, 0.88, 0.88, 0.88)
        tooltip:AddDoubleLine("Characters Met " .. charactersMet, "Renown " .. renown, 0.78, 0.80, 0.84, 1.00, 0.76, 0.18)
    end

    -- TRP's custom tooltip has already completed its Build()/Show() pass by
    -- the time third-party OnShow hooks run. Adding lines afterwards updates
    -- the line pool, but its geometry can remain at the old height until a
    -- second layout pass. Known working TRP tooltip extensions use this same
    -- Show/GetTop nudge after appending their lines.
    if forceLayout and tooltip.IsShown and tooltip:IsShown() then
        if tooltip.Show then pcall(tooltip.Show, tooltip) end
        if tooltip.GetTop then pcall(tooltip.GetTop, tooltip) end
    end
    return true
end

local function IsSecretValue(value)
    if type(_G.issecretvalue)~="function" then return false end
    local ok,result=pcall(_G.issecretvalue,value)
    return ok and result and true or false
end

local function SafePlainString(value)
    if IsSecretValue(value) then return nil end
    local ok,result=pcall(tostring,value)
    if not ok or type(result)~="string" or result=="" then return nil end
    return result
end

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

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

local function UnitFullTarget(unit)
    if IsSecretValue(unit) or not SafeUnitExists(unit) or not SafeUnitIsPlayer(unit) then return nil end
    local name, realm
    if UnitFullName then
        local ok,n,r=pcall(UnitFullName,unit)
        if ok then name,realm=n,r end
    end
    local safeName=SafePlainString(name)
    local safeRealm=SafePlainString(realm)
    if not safeName and UnitName then
        local ok,n=pcall(UnitName,unit)
        if ok then safeName=SafePlainString(n) end
    end
    if not safeName then return nil end
    if safeRealm then return safeName .. "-" .. safeRealm end
    return safeName
end

local function TooltipUnit(tooltip)
    if tooltip and tooltip.GetUnit then
        local ok, _, unit = pcall(tooltip.GetUnit, tooltip)
        -- Midnight can return protected/secret unit tokens from combat tooltips.
        -- Never compare, stringify or otherwise inspect those values; simply skip
        -- the ODYN augmentation for that protected tooltip pass.
        if ok and not IsSecretValue(unit) and SafeUnitExists(unit) then
            return unit
        end
    end
    if SafeUnitExists("mouseover") then return "mouseover" end
    return nil
end

local function TooltipTarget(tooltip)
    if tooltip then
        local cached=SafePlainString(tooltip._rpalHUDTarget)
        if cached then return cached end
    end
    local unit = TooltipUnit(tooltip)
    return UnitFullTarget(unit)
end

local function IsSelfTarget(target)
    if not target or target == "" then return false end
    return NamesEquivalent(target, PlayerFullName())
end

local function SendTipMessage(message, target)
    if not C_ChatInfo or not C_ChatInfo.SendAddonMessage then return false end
    if not target or target == "" or #tostring(message or "") > 255 then return false end
    if RPAL.SendAddonMessageSafe then return RPAL.SendAddonMessageSafe(TIP_PREFIX,message,"WHISPER",target,true) end
    local ok, result = pcall(C_ChatInfo.SendAddonMessage, TIP_PREFIX, message, "WHISPER", target)
    return ok and (result == nil or result == true or tonumber(result) == 0)
end

local function QueryRemoteCompanion(target)
    if not target or target == "" or IsSelfTarget(target) then return false end
    local now = (GetTime and GetTime()) or 0
    local key = NormalizeName(target)
    if now - (tipQueries[key] or -1000) < TIP_QUERY_COOLDOWN then return false end
    tipQueries[key] = now
    return SendTipMessage("Q;" .. tostring(TIP_PROTOCOL), target)
end

local function CachedRemoteData(target)
    local targetKey = NormalizeName(target)
    local entryKey = targetKey
    local entry = tipCache[targetKey]
    if not entry and not targetKey:find("-", 1, true) then
        for key, candidate in pairs(tipCache) do
            if ShortName(key) == ShortName(targetKey) then
                entryKey, entry = key, candidate
                break
            end
        end
    end
    if not entry then return nil end
    local now = (GetTime and GetTime()) or 0
    local age = now - (entry.receivedAt or 0)
    if age > TIP_CACHE_STALE_TTL then
        tipCache[entryKey] = nil
        return nil
    end
    if entry.hidden then return nil end
    return entry.data, age > TIP_CACHE_FRESH_TTL
end

local function RenderForTarget(tooltip, target, forceLayout)
    if not tooltip or not target or target == "" then return false end
    if tooltip.IsShown and not tooltip:IsShown() then return false end

    if IsSelfTarget(target) then
        local hidden = CompanionIsHidden()
        if hidden then return false end
        return RenderCompanionBlock(tooltip, LocalCompanionData(), forceLayout)
    end

    local cached = CachedRemoteData(target)
    if cached then
        RenderCompanionBlock(tooltip, cached, forceLayout)
    end
    QueryRemoteCompanion(target)
    return cached ~= nil
end

local function QueueFinalRender(tooltip, target)
    if not tooltip or not target or target == "" then return end
    local expected = NormalizeName(target)
    local function Run()
        if not tooltip or (tooltip.IsShown and not tooltip:IsShown()) then return end
        local current = TooltipTarget(tooltip)
        if not current or NormalizeName(current) ~= expected then return end
        RenderForTarget(tooltip, current, true)
    end
    if C_Timer and C_Timer.After then C_Timer.After(0, Run) else Run() end
end

local function OnUnitTooltip(tooltip, data)
    if tooltip ~= _G.GameTooltip then return end
    local unit = TooltipUnit(tooltip)
    if not unit or not SafeUnitIsPlayer(unit) then return end
    local target = UnitFullTarget(unit)
    if not target or target == "" then return end

    tooltip._rpalHUDTarget = target
    tooltip._rpalHUDUnit = unit
    -- Defer the actual append until the current tooltip processing pass is
    -- finished, so TRP/other tooltip addons keep their native ordering and
    -- RPAL lands as the final separate section.
    QueueFinalRender(tooltip, target)
end

local function FallbackGameTooltipShow(tooltip)
    if tooltip ~= _G.GameTooltip then return end
    local unit = TooltipUnit(tooltip)
    if not unit or not SafeUnitIsPlayer(unit) then return end
    local target = UnitFullTarget(unit)
    if not target or target == "" then return end
    tooltip._rpalHUDTarget = target
    tooltip._rpalHUDUnit = unit
    QueueFinalRender(tooltip, target)
end

local function TRPTooltipTarget(tooltip)
    if not tooltip then return nil end

    -- Current TRP3 keeps the live WoW unit token in .targetType and the
    -- normalized TRP character ID in .target. Prefer the live token so realm
    -- qualification matches addon-message sender names.
    local unit = tooltip.targetType
    if not IsSecretValue(unit) and SafeUnitExists(unit) then
        local liveTarget = UnitFullTarget(unit)
        if liveTarget then return liveTarget end
    end
    if SafeUnitExists("mouseover") then
        local liveTarget = UnitFullTarget("mouseover")
        if liveTarget then return liveTarget end
    end
    local targetText=SafePlainString(tooltip.target)
    if targetText then return targetText end
    local cachedText=SafePlainString(tooltip._rpalHUDTarget)
    if cachedText then return cachedText end
    return nil
end

local function RenderTRPNow(tooltip, source)
    if not tooltip then
        lastTRPRender = { result = "no_tooltip", source = source or "unknown" }
        return false
    end

    local target = TRPTooltipTarget(tooltip)
    local before = tooltip.NumLines and tooltip:NumLines() or -1
    lastTRPRender = {
        source = source or "unknown",
        rawTarget = SafePlainString(tooltip.target) or (IsSecretValue(tooltip.target) and "<protected>" or "nil"),
        targetType = SafePlainString(tooltip.targetType) or (IsSecretValue(tooltip.targetType) and "<protected>" or "nil"),
        resolvedTarget = target or "nil",
        beforeLines = before,
        shown = (tooltip.IsShown and tooltip:IsShown()) and true or false,
    }

    if not target or target == "" then
        lastTRPRender.result = "no_target"
        return false
    end

    tooltip._rpalHUDTarget = target
    local ok, rendered = pcall(RenderForTarget, tooltip, target, true)
    if not ok then
        lastTRPRender.result = "error:" .. tostring(rendered)
        return false
    end

    lastTRPRender.afterLines = tooltip.NumLines and tooltip:NumLines() or -1
    lastTRPRender.companionLine = FindCompanionLine(tooltip)
    lastTRPRender.result = rendered and "rendered" or (IsSelfTarget(target) and "self_not_rendered" or "remote_waiting_or_hidden")
    return rendered
end

local function QueueTRPFinalRender(tooltip)
    if not tooltip then return end
    local target = TRPTooltipTarget(tooltip)
    if not target or target == "" then
        RenderTRPNow(tooltip, "queue_no_target")
        return
    end
    tooltip._rpalHUDTarget = target
    local expected = NormalizeName(target)
    local function Run()
        if not tooltip or (tooltip.IsShown and not tooltip:IsShown()) then return end
        local current = TRPTooltipTarget(tooltip)
        if not current or NormalizeName(current) ~= expected then return end
        tooltip._rpalHUDTarget = current
        RenderTRPNow(tooltip, "deferred_ensure")
    end
    if C_Timer and C_Timer.After then C_Timer.After(0, Run) else Run() end
end

local function StopTRPEnsureTimer(tooltip)
    if not tooltip then return end
    local ticker=tooltip._rpalHUDEnsureTicker
    if ticker and type(ticker.Cancel)=="function" then pcall(ticker.Cancel,ticker) end
    tooltip._rpalHUDEnsureTicker=nil
end

local function EnsureTRPCompanionLine(tooltip,source)
    if not tooltip or not tooltip.IsShown or not tooltip:IsShown() then return false end
    local target=TRPTooltipTarget(tooltip)
    if not target or target=="" then return false end
    tooltip._rpalHUDTarget=target
    if not FindCompanionLine(tooltip) then RenderTRPNow(tooltip,source or "timer_ensure") end
    return true
end

local function StartTRPEnsureTimer(tooltip)
    StopTRPEnsureTimer(tooltip)
    if not tooltip or not C_Timer or type(C_Timer.NewTicker)~="function" then return false end
    local ok,ticker=pcall(C_Timer.NewTicker,0.20,function()
        if not tooltip or not tooltip.IsShown or not tooltip:IsShown() then
            StopTRPEnsureTimer(tooltip)
        else
            EnsureTRPCompanionLine(tooltip,"timer_ensure")
        end
    end)
    if ok then tooltip._rpalHUDEnsureTicker=ticker;return true end
    return false
end

local function TryHookTRPTooltip()
    if trpTooltipHooked then return true end
    local tooltip = _G.TRP3_CharacterTooltip
    if not tooltip or type(tooltip.HookScript) ~= "function" then
        trpTooltipHookMode = "waiting_for_TRP3_CharacterTooltip"
        return false
    end

    tooltip:HookScript("OnShow", function(self)
        -- TRP's builder has completed when OnShow fires. Append immediately,
        -- then force the same geometry refresh used by working TRP extensions.
        local ok, err = pcall(RenderTRPNow, self, "OnShow")
        if not ok then lastTRPRender = { result = "OnShow_error:" .. tostring(err), source = "OnShow" } end
        QueueTRPFinalRender(self)
        StartTRPEnsureTimer(self)
    end)
    tooltip:HookScript("OnHide", function(self)
        StopTRPEnsureTimer(self)
        self._rpalHUDTarget = nil
        self._rpalHUDLastEnsure = nil
    end)
    -- TRP can rebuild a tooltip in-place after profile data arrives. Its frame
    -- may stay shown, so OnShow alone is insufficient. Modern clients use a
    -- visible-only 5 Hz timer instead of invoking an ODYN hook every frame.
    -- Retain the old throttled callback solely as a compatibility fallback.
    if not (C_Timer and type(C_Timer.NewTicker)=="function") then
        tooltip:HookScript("OnUpdate", function(self)
            if not self.IsShown or not self:IsShown() then return end
            local now = (GetTime and GetTime()) or 0
            if now - (self._rpalHUDLastEnsure or -1000) < 0.20 then return end
            self._rpalHUDLastEnsure = now
            EnsureTRPCompanionLine(self,"OnUpdate_ensure")
        end)
    elseif tooltip.IsShown and tooltip:IsShown() then
        StartTRPEnsureTimer(tooltip)
    end

    trpTooltipHooked = true
    trpTooltipHookMode = "TRP3_CharacterTooltip/OnShow immediate + layout + ensure"
    RPAL._trpHUDCompanionHooked = true
    return true
end

local function ReportTooltipError(prefix,err)
    -- Keep tooltip callback failures isolated from normal chat.
end

local function TryHookTooltip()
    TryHookTRPTooltip()

    -- Retail's supported tooltip data pipeline.  This fires for unit tooltips
    -- regardless of whether TRP is installed.
    if not tooltipProcessorRegistered
       and type(_G.TooltipDataProcessor) == "table"
       and type(_G.TooltipDataProcessor.AddTooltipPostCall) == "function"
       and type(_G.Enum) == "table"
       and type(_G.Enum.TooltipDataType) == "table"
       and _G.Enum.TooltipDataType.Unit ~= nil then
        local ok = pcall(_G.TooltipDataProcessor.AddTooltipPostCall, _G.Enum.TooltipDataType.Unit, function(tooltip, data)
            local success, err = pcall(OnUnitTooltip, tooltip, data)
            if not success then ReportTooltipError("ODYN HUD tooltip error:",err) end
        end)
        if ok then
            tooltipProcessorRegistered = true
            tooltipHooked = true
            tooltipHookMode = "TooltipDataProcessor/Unit"
        end
    end

    -- Fallback for environments where the modern processor is unavailable.
    -- We intentionally hook Blizzard's GameTooltip, never a private TRP frame.
    local tooltip = _G.GameTooltip
    if not tooltipHooked and tooltip and type(tooltip.HookScript) == "function" then
        tooltip:HookScript("OnShow", function(self)
            local ok, err = pcall(FallbackGameTooltipShow, self)
            if not ok then ReportTooltipError("ODYN HUD fallback error:",err) end
        end)
        tooltipHooked = true
        tooltipHookMode = "GameTooltip/OnShow fallback"
    end

    if tooltip and type(tooltip.HookScript) == "function" and not tooltip._rpalHUDHideHooked then
        tooltip._rpalHUDHideHooked = true
        tooltip:HookScript("OnHide", function(self)
            self._rpalHUDTarget = nil
            self._rpalHUDUnit = nil
        end)
    end

    RPAL._hudCompanionHooked = tooltipHooked
    RPAL._trpHUDCompanionHooked = trpTooltipHooked
    return tooltipHooked or trpTooltipHooked
end

local function RefreshVisibleSelfTooltip()
    TryHookTooltip()
    local hidden = CompanionIsHidden()
    if hidden then return end

    local tooltip = _G.GameTooltip
    if tooltip and tooltip.IsShown and tooltip:IsShown() then
        local target = TooltipTarget(tooltip)
        if target and IsSelfTarget(target) then
            RenderCompanionBlock(tooltip, LocalCompanionData(), true)
        end
    end

    local trpTooltip = _G.TRP3_CharacterTooltip
    if trpTooltip and trpTooltip.IsShown and trpTooltip:IsShown() then
        local target = TRPTooltipTarget(trpTooltip)
        if target and IsSelfTarget(target) then
            trpTooltip._rpalHUDTarget = target
            RenderCompanionBlock(trpTooltip, LocalCompanionData(), true)
        end
    end
end

local function HandleTipMessage(prefix, text, channel, sender)
    if prefix ~= TIP_PREFIX or channel ~= "WHISPER" then return end
    if type(text) ~= "string" or #text > 255 or type(sender) ~= "string" or sender == "" then return end
    if NamesEquivalent(sender, PlayerFullName()) then return end

    local fields = {}
    for field in text:gmatch("([^;]+)") do fields[#fields + 1] = field end
    local kind = fields[1]
    local protocol = tonumber(fields[2])
    if protocol ~= TIP_PROTOCOL then return end

    if kind == "Q" then
        local hidden = CompanionIsHidden()
        if hidden then
            SendTipMessage("H;" .. tostring(TIP_PROTOCOL), sender)
            return
        end
        local data = LocalCompanionData()
        SendTipMessage(table.concat({
            "S", tostring(TIP_PROTOCOL), tostring(data.level), tostring(data.sessions),
            tostring(data.charactersMet), tostring(data.renown)
        }, ";"), sender)
        return
    end

    local key = NormalizeName(sender)
    local now = (GetTime and GetTime()) or 0
    if kind == "H" then
        PruneTipCache()
        tipCache[key] = {hidden = true, receivedAt = now}
        return
    end

    if kind ~= "S" then return end

    local level = tonumber(fields[3])
    local sessions = tonumber(fields[4])
    local charactersMet = tonumber(fields[5])
    local renown = tonumber(fields[6])
    if not level or not sessions or not charactersMet or not renown then return end
    if level < 1 or level > 10000 then return end
    if sessions < 0 or sessions > 10000000 then return end
    if charactersMet < 0 or charactersMet > 10000000 then return end
    if renown < 0 or renown > 1000000000 then return end

    local data = {
        level = math.floor(level),
        sessions = math.floor(sessions),
        charactersMet = math.floor(charactersMet),
        renown = math.floor(renown),
    }
    PruneTipCache()
    tipCache[key] = {hidden = false, data = data, receivedAt = now}

    local tooltip = _G.GameTooltip
    if tooltip and tooltip.IsShown and tooltip:IsShown() then
        local target = TooltipTarget(tooltip)
        if target and NamesEquivalent(target, sender) then
            tooltip._rpalHUDTarget = target
            RenderCompanionBlock(tooltip, data, true)
        end
    end

    local trpTooltip = _G.TRP3_CharacterTooltip
    if trpTooltip and trpTooltip.IsShown and trpTooltip:IsShown() then
        local target = TRPTooltipTarget(trpTooltip)
        if target and NamesEquivalent(target, sender) then
            trpTooltip._rpalHUDTarget = target
            RenderCompanionBlock(trpTooltip, data, true)
        end
    end
end

function RPAL.RefreshTRPCompanionSummary(force)
    local changed, reason = CleanupLegacyOOCMirror()
    local adChanged, adReason = SyncTRPProfileAdvertiser()
    TryHookTooltip()
    if force then RefreshVisibleSelfTooltip() end
    if changed or adChanged then
        return true, tostring(reason) .. "; profile_ad=" .. tostring(adReason)
    end
    local route = trpTooltipHooked and "trp_hover_ready" or (tooltipHooked and "blizzard_hover_ready" or "waiting_for_tooltip")
    return false, route .. "; " .. tostring(reason) .. "; profile_ad=" .. tostring(adReason)
end

function RPAL.IsTRPCompanionHidden()
    return CompanionIsHidden()
end

function RPAL.QueueTRPCompanionSummaryRefresh(delay)
    if refreshQueued then return end
    refreshQueued = true
    local function Run()
        refreshQueued = false
        RPAL.RefreshTRPCompanionSummary(false)
        RefreshVisibleSelfTooltip()
    end
    if C_Timer and C_Timer.After then
        C_Timer.After(math.max(0, tonumber(delay) or 0.08), Run)
    else
        Run()
    end
end

-- Keep a visible self tooltip current when local progression/session values move.
do
    local previous = RPAL.OnStatisticsChanged
    RPAL.OnStatisticsChanged = function(...)
        if previous then previous(...) end
        RPAL.QueueTRPCompanionSummaryRefresh(0.05)
    end
end

do
    local previous = RPAL.OnRenownLevelUp
    RPAL.OnRenownLevelUp = function(...)
        if previous then previous(...) end
        RPAL.QueueTRPCompanionSummaryRefresh(0.05)
    end
end

local function WrapRefresh(name)
    local previous = RPAL[name]
    if type(previous) ~= "function" then return end
    RPAL[name] = function(...)
        local results = {previous(...)}
        RPAL.QueueTRPCompanionSummaryRefresh(0.05)
        return unpack(results)
    end
end
WrapRefresh("EndScene")
WrapRefresh("EndStoredScene")
WrapRefresh("DeleteScene")
WrapRefresh("OnTRPProfileScopeChanged")

local eventFrame = CreateFrame and CreateFrame("Frame")
if eventFrame then
    eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
    eventFrame:RegisterEvent("ADDON_LOADED")
    eventFrame:RegisterEvent("CHAT_MSG_ADDON")
    eventFrame:RegisterEvent("PLAYER_LOGOUT")
    eventFrame:SetScript("OnEvent", function(_, event, ...)
        if event == "CHAT_MSG_ADDON" then
            HandleTipMessage(...)
            return
        elseif event == "PLAYER_LOGOUT" then
            CleanupTRPProfileAdvertiserForLogout()
            return
        end

        if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then
            pcall(C_ChatInfo.RegisterAddonMessagePrefix, TIP_PREFIX)
        end
        TryHookTooltip()
        RPAL.QueueTRPCompanionSummaryRefresh(0.25)
        if C_Timer and C_Timer.After then
            C_Timer.After(1.25, function()
                TryHookTooltip()
                RPAL.RefreshTRPCompanionSummary(false)
            end)
        end
    end)
end

if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then
    pcall(C_ChatInfo.RegisterAddonMessagePrefix, TIP_PREFIX)
end
TryHookTooltip()
RPAL.QueueTRPCompanionSummaryRefresh(0.15)
