local ADDON_NAME, RPAL = ...
local ODYN_VERSION = "1.8.1"
local ODYN_BUILD = "Stable"
RPAL.VERSION = ODYN_VERSION
RPAL.BUILD = ODYN_BUILD
RPAL.PERSONA_VERSION = "0.6.4"

-- Register slash commands from Core.lua so they still exist even if UI.lua
-- encounters a load-time error. The handler checks whether the UI is ready.
SLASH_RPAL1 = "/odyn"
SLASH_RPAL2 = "/rpal" -- legacy alias
SLASH_RPAL3 = "/ral" -- legacy alias
if not SlashCmdList["GLANCE"] then
    SLASH_RPALGLANCE1="/glance"
    SlashCmdList["RPALGLANCE"]=function(msg) DoEmote("GLANCE",msg) end
end
if not SlashCmdList["HISS"] then
    SLASH_RPALHISS1="/hiss"
    SlashCmdList["RPALHISS"]=function(msg) DoEmote("HISS",msg) end
end


SlashCmdList["RPAL"] = function(msg)
    msg=tostring(msg or ""):lower():gsub("^%s+",""):gsub("%s+$","")

    if msg=="help" or msg=="?" then
        print("|cffd9a644ODYN|r — Roleplay profile, progression and social tools")
        print("|cffffffff/odyn|r — Open or close ODYN")
        print("|cffffffff/odyn version|r — Show the installed ODYN version")
        print("|cffffffff/odyn profile|r — Preview your public ODYN profile")
        if RPAL.IsLiteKit and RPAL.IsLiteKit() then
            print("|cffaaaaaaLite Kit active: Renown Atlas and Appearance Vault are disabled.|r")
        else
            print("|cffffffff/odyn atlas|r — Open the Renown Atlas")
            print("|cffffffff/odyn customize|r — Open Appearance Vault")
        end
        print("|cffffffff/odyn settings|r — Open ODYN settings")
        print("|cffffffff/odyn seasons|r — Open seasonal progression")
        print("|cffffffff/odyn achievements|r — Open ODYN directly to Achievements")
        print("|cffffffff/odyn skillset|r — Open the character Skill Set")
        print("|cffffffff/odyn injuries|r — Open Health")
        print("|cffffffff/odyn persona|r — Open the Persona Beta Observatory")
        print("|cffffffff/odyn calibrate|r — Re-run the guided Persona Beta calibration")
        print("|cffffffff/odyn persona voice|r — View calibrated voice; configure profanity and living habits")
        print("|cffffffff/odyn currencies|r — Open RP Currency, Coin Pouch and Bank")
        print("|cffffffff/odyn sessions|r — Open active, paused and recorded Sessions, Bonds and Story Arcs")
        print("|cffffffff/odyn calendar|r — Open your character RP calendar")
        print("|cffffffff/odyn session start|r / |cffffffff/odyn session end|r — Quick Session controls")
        print("|cffffffff/odyn fight|r — Challenge your current target to combat")
        print("|cffffffff/odyn dm|r — Open the DM Kit")
        print("|cffffffff/odyn test|r — Show temporary presentation QA commands")
        print("|cff888888/rpal and /ral remain available as legacy shortcuts.|r")
        return
    end

    -- TEMP QA COMMANDS: presentation-only. They exercise the real ODYN
    -- notification / toast / Renown FX pipelines without changing progression.
    if msg=="test" or msg:match("^test%s") then
        local args=msg:match("^test%s*(.-)%s*$") or ""
        if RPAL.RunQATest then
            local ok,detail=RPAL.RunQATest(args)
            if detail and detail~="" then
                print((ok and "|cffd9a644ODYN QA:|r " or "|cffff7777ODYN QA:|r ")..tostring(detail))
            end
        else
            print("|cffff7777ODYN QA:|r Test module is not ready. Try /reload and run the command again.")
        end
        return
    end


    if msg=="perf" or msg=="performance" then
        local settings=RPAL.db and RPAL.db.settings or {}
        local ps=RPAL._perfStats or {}
        local persona=settings.personaEnabled==false and "OFF" or "ON"
        print("|cff66e6ffODYN PERF|r  Persona setting: "..persona)
        print("|cffaaaaaaPersona event hooks:|r learning="..tostring(RPAL._personaLearningEventsEnabled==true).." continuity="..tostring(RPAL._personaContinuityEventsEnabled==true).." cognition="..tostring(RPAL._personaCognitionEventsEnabled==true))
        print("|cffaaaaaaMSP resolver:|r full scans="..tostring(ps.mspFullScans or 0).." record-cache hits="..tostring(ps.mspRecordCacheHits or 0).." field-cache hits="..tostring(ps.mspFieldCacheHits or 0))
        print("|cffaaaaaaPersona queue:|r pending="..tostring(RPAL._personaWorkPending or 0).." jobs="..tostring(ps.personaJobs or 0).." last="..string.format("%.2f",tonumber(ps.personaLastMs)or 0).."ms max="..string.format("%.2f",tonumber(ps.personaMaxMs)or 0).."ms drops="..tostring(ps.personaQueueDrops or 0))
        print("|cffaaaaaaHealth refresh:|r recursion guard hotfix active")
        return
    end

    if msg=="version" or msg=="ver" then
        local version=(C_AddOns and C_AddOns.GetAddOnMetadata and C_AddOns.GetAddOnMetadata("ODYN","Version")) or (GetAddOnMetadata and GetAddOnMetadata("ODYN","Version")) or ODYN_VERSION
        local build=(C_AddOns and C_AddOns.GetAddOnMetadata and C_AddOns.GetAddOnMetadata("ODYN","X-ODYN-Build")) or (GetAddOnMetadata and GetAddOnMetadata("ODYN","X-ODYN-Build")) or ODYN_BUILD
        print("|cff66e6ffODYN|r |cffffffff"..tostring(version or ODYN_VERSION).." • "..tostring(build or ODYN_BUILD).."|r  •  Observe. Document. Your Narrative.")
        return
    end

    if not (RPAL.UI and RPAL.UI.ready and RPAL.UI.main) then
        print("|cffff6666ODYN:|r UI did not finish loading. The first UI error is printed during login/reload.")
        return
    end

    if msg=="profile" or msg=="preview" then
        if RPAL.OpenSelfPublicPreview then RPAL.OpenSelfPublicPreview(false) end
        return
    elseif msg=="atlas" or msg=="talents" then
        if RPAL.OpenTalentAtlas then RPAL.OpenTalentAtlas() end
        return
    elseif msg=="customize" or msg=="customization" or msg=="appearance" then
        if RPAL.OpenCustomization then RPAL.OpenCustomization() end
        return
    elseif msg=="settings" or msg=="options" then
        if RPAL.OpenSettings then RPAL.OpenSettings() end
        return
    elseif msg=="season" or msg=="seasons" or msg=="open beta" then
        if RPAL.OpenSeasonsUI then RPAL.OpenSeasonsUI() else print("|cffff6666ODYN:|r Seasons UI is not ready yet.") end
        return
    elseif msg=="skillset" or msg=="skills" or msg=="skill set" then
        if RPAL.OpenSkillSet then RPAL.OpenSkillSet() end
        return
    elseif msg=="persona voice" or msg=="persona habits" or msg=="voice habits" or msg=="living persona" then
        if RPAL.OpenPersonaLivingConfig then RPAL.OpenPersonaLivingConfig() else print("|cffff6666ODYN:|r Living Habits is not ready yet.") end
        return
    elseif msg=="personality" or msg=="persona" or msg=="inner voice" then
        if RPAL.OpenPersonalityUI then RPAL.OpenPersonalityUI() else print("|cffff6666ODYN:|r Persona Beta UI is not ready yet.") end
        return
    elseif msg=="calibrate" or msg=="persona calibrate" or msg=="personality calibrate" then
        if RPAL.OpenPersonalityCalibration then RPAL.OpenPersonalityCalibration() else print("|cffff6666ODYN:|r Persona Beta calibration is not ready yet.") end
        return
    elseif msg=="injuries" or msg=="injury" then
        if RPAL.OpenInjurySystem then RPAL.OpenInjurySystem() end
        return
    elseif msg=="currency" or msg=="currencies" or msg=="coin" or msg=="coins" or msg=="bank" then
        if RPAL.OpenCurrencyUI then RPAL.OpenCurrencyUI() else print("|cffff6666ODYN:|r RP Currency UI is not ready yet.") end
        return
    elseif msg=="achievements" or msg=="achievement" then
        if not RPAL.UI.main:IsShown() then RPAL.UI.main:Show() end
        if RPAL.SetLedgerMode then RPAL.SetLedgerMode(true)
        elseif RPAL.UI.ledgerToggle and RPAL.UI.ledgerToggle.Click then RPAL.UI.ledgerToggle:Click() end
        if RPAL.Refresh then RPAL.Refresh() end
        return
    elseif msg=="chronicle" or msg=="journal" or msg=="sessions" or msg=="scenes" then
        if RPAL.OpenChronicle then RPAL.OpenChronicle("Scenes") end
        return
    elseif msg=="calendar" or msg=="schedule" or msg=="events" then
        if RPAL.OpenCalendar then RPAL.OpenCalendar() end
        return
    elseif msg=="session" or msg=="scene" then
        if RPAL.OpenChronicle then RPAL.OpenChronicle("Scenes") end
        return
    elseif msg=="dm" or msg=="dmkit" or msg=="dm kit" then
        if RPAL.OpenDMKit then RPAL.OpenDMKit() end
        return
    elseif msg=="fight" or msg=="combat" then
        local ok,result=RPAL.Combat and RPAL.Combat.StartRequest and RPAL.Combat.StartRequest()
        if not ok and result then print("|cffff6666ODYN Combat:|r "..result) end
        return
    elseif msg=="fight end" or msg=="combat end" then
        if RPAL.Combat and RPAL.Combat.End then RPAL.Combat.End() end
        return
    elseif msg=="session start" or msg=="scene start" then
        local ok,result=RPAL.StartScene and RPAL.StartScene()
        if not ok then print("|cffff7777ODYN:|r "..tostring(result or "Could not start Session.")) end
        return
    elseif msg=="session end" or msg=="scene end" then
        local ok,result
        if RPAL.RequestEndScene then ok,result=RPAL.RequestEndScene()
        else ok,result=RPAL.EndScene and RPAL.EndScene() end
        if not ok then print("|cffff7777ODYN:|r "..tostring(result or "No Session is active.")) end
        return
    elseif msg~="" then
        print("|cffffcc66ODYN:|r Unknown command. Use |cffffffff/odyn help|r.")
        return
    end

    if RPAL.ToggleMainUI then
        RPAL.ToggleMainUI()
    elseif RPAL.UI.main:IsShown() then
        RPAL.UI.main:Hide()
    else
        RPAL.UI.main:Show()
        if RPAL.Refresh then RPAL.Refresh() end
    end
end


local function Copy(src)
    if type(src) ~= "table" then return src end
    local out = {}
    for k, v in pairs(src) do out[Copy(k)] = Copy(v) end
    return out
end
RPAL.Copy = Copy

-- Reliable addon-message transport -------------------------------------------
-- Dragonflight-era clients and later return Enum.SendAddonMessageResult from
-- C_ChatInfo.SendAddonMessage. A non-zero enum is a failure, not a truthy
-- success value. Several older ODYN transports treated every numeric result as
-- success, which made throttled packets disappear silently during busy online
-- features (Hearthstone, DM Kit, Combat and Online Exchange).
local ADDON_SEND_QUEUES={}

local function AddonQueueLimits(prefix)
    -- Public inspect snapshots are ordered, multi-packet transfers and may be
    -- larger than combat/DM commands. Give only that prefix a deeper queue and
    -- a longer recovery window; interactive traffic stays tightly bounded.
    if prefix=="RPAL_SYNC" then return 900,60 end
    return 320,18
end

local function NewAddonQueue()
    return {items={},head=1,tail=0,sent=0,throttled=0,dropped=0}
end

local function AddonQueueSize(queue)
    if not queue then return 0 end
    return math.max(0,(tonumber(queue.tail) or 0)-(tonumber(queue.head) or 1)+1)
end

local function AddonQueuePeek(queue)
    if AddonQueueSize(queue)<=0 then return nil end
    return queue.items[queue.head]
end

local function AddonQueuePush(queue,item)
    queue.tail=(tonumber(queue.tail) or 0)+1
    queue.items[queue.tail]=item
end

local function AddonQueuePop(queue)
    if AddonQueueSize(queue)<=0 then return nil end
    local item=queue.items[queue.head]
    queue.items[queue.head]=nil
    queue.head=queue.head+1
    if queue.head>queue.tail then
        queue.items={};queue.head=1;queue.tail=0
    elseif queue.head>1024 and AddonQueueSize(queue)<256 then
        local compact={};local index=0
        for i=queue.head,queue.tail do index=index+1;compact[index]=queue.items[i] end
        queue.items=compact;queue.head=1;queue.tail=index
    end
    return item
end

local function AddonSendResultCode(result)
    if result==nil or result==true then return 0 end
    if result==false then return -1 end
    return tonumber(result) or -1
end

local function AddonThrottleCode()
    local enum=_G.Enum and _G.Enum.SendAddonMessageResult
    return enum and tonumber(enum.AddonMessageThrottle) or 3
end

local function TryAddonMessageNow(prefix,message,chatType,target)
    if not C_ChatInfo or type(C_ChatInfo.SendAddonMessage)~="function" then return false,"unavailable" end
    local ok,result=pcall(C_ChatInfo.SendAddonMessage,prefix,message,chatType,target)
    if not ok then return false,"lua_error" end
    local code=AddonSendResultCode(result)
    if code==0 then return true,"sent" end
    if code==AddonThrottleCode() then return false,"throttled" end
    return false,"send_error_"..tostring(code)
end

local function ScheduleAddonQueue(prefix,delay)
    local queue=ADDON_SEND_QUEUES[prefix]
    if not queue or queue.scheduled or not C_Timer or not C_Timer.After then return end
    queue.scheduled=true
    C_Timer.After(math.max(0.02,tonumber(delay) or 0.10),function()
        queue.scheduled=false
        local now=(GetTime and GetTime()) or 0
        local first=AddonQueuePeek(queue)
        while first and now>(tonumber(first.expiresAt) or 0) do
            AddonQueuePop(queue)
            queue.dropped=(tonumber(queue.dropped) or 0)+1
            first=AddonQueuePeek(queue)
        end
        local item=AddonQueuePeek(queue)
        if not item then return end
        local sent,reason=TryAddonMessageNow(item.prefix,item.message,item.chatType,item.target)
        if sent then
            AddonQueuePop(queue)
            queue.sent=(tonumber(queue.sent) or 0)+1
            if AddonQueuePeek(queue) then ScheduleAddonQueue(prefix,0.035) end
        elseif reason=="throttled" then
            queue.throttled=(tonumber(queue.throttled) or 0)+1
            ScheduleAddonQueue(prefix,0.20)
        else
            AddonQueuePop(queue)
            queue.dropped=(tonumber(queue.dropped) or 0)+1
            if AddonQueuePeek(queue) then ScheduleAddonQueue(prefix,0.05) end
        end
    end)
end

function RPAL.SendAddonMessageSafe(prefix,message,chatType,target,queueOnThrottle)
    prefix=tostring(prefix or "")
    message=tostring(message or "")
    chatType=tostring(chatType or "WHISPER")
    if prefix=="" or #prefix>16 or message=="" or #message>255 then return false,"invalid_payload" end
    if chatType=="WHISPER" and (not target or tostring(target)=="") then return false,"invalid_target" end

    local queue=ADDON_SEND_QUEUES[prefix]
    local maxQueue,retryWindow=AddonQueueLimits(prefix)
    -- Once a prefix has entered the retry queue, preserve FIFO ordering for
    -- every following packet. This matters for multi-chunk Hearthstone state:
    -- a later chunk/turn command must not leapfrog a throttled earlier chunk.
    if queue and AddonQueueSize(queue)>0 and queueOnThrottle~=false then
        if AddonQueueSize(queue)>=maxQueue then
            queue.dropped=(tonumber(queue.dropped) or 0)+1
            return false,"queue_full"
        end
        local now=(GetTime and GetTime()) or 0
        AddonQueuePush(queue,{prefix=prefix,message=message,chatType=chatType,target=target,expiresAt=now+retryWindow})
        ScheduleAddonQueue(prefix,0.04)
        return true,"queued_ordered"
    end

    local sent,reason=TryAddonMessageNow(prefix,message,chatType,target)
    if sent then return true,"sent" end
    if reason~="throttled" or queueOnThrottle==false then return false,reason end
    if not C_Timer or not C_Timer.After then return false,reason end

    if not queue then queue=NewAddonQueue();ADDON_SEND_QUEUES[prefix]=queue end
    if AddonQueueSize(queue)>=maxQueue then
        queue.dropped=(tonumber(queue.dropped) or 0)+1
        return false,"queue_full"
    end
    local now=(GetTime and GetTime()) or 0
    AddonQueuePush(queue,{prefix=prefix,message=message,chatType=chatType,target=target,expiresAt=now+retryWindow})
    ScheduleAddonQueue(prefix,0.12)
    return true,"queued"
end

-- Experience setup / Full vs Lite kit ---------------------------------------
RPAL.EXPERIENCE_SETUP_VERSION = 2

function RPAL.GetExperienceMode()
    local settings
    if RPAL.GetSettings then
        settings=RPAL.GetSettings()
    elseif type(RPAL_DB)=="table" then
        RPAL_DB.settings=RPAL_DB.settings or {}
        settings=RPAL_DB.settings
    else
        settings={}
    end
    return settings.experienceMode=="lite" and "lite" or "full"
end

function RPAL.IsLiteKit()
    return RPAL.GetExperienceMode()=="lite"
end

function RPAL.IsProgressionEnabled()
    return not RPAL.IsLiteKit()
end

function RPAL.SetExperienceMode(mode)
    mode=tostring(mode or "full"):lower()=="lite" and "lite" or "full"
    local settings=RPAL.GetSettings and RPAL.GetSettings() or nil
    if not settings then return false,"Settings are not ready yet." end
    settings.experienceMode=mode
    settings.experienceSetupVersion=RPAL.EXPERIENCE_SETUP_VERSION or 1

    -- Lite is a presentation/feature mode, not a destructive reset. Existing
    -- progression, talent allocation and cosmetic ownership remain untouched
    -- so switching back to Full Kit resumes exactly where the player left off.
    if mode=="lite" then
        if RPAL.CloseTalentAtlas then pcall(RPAL.CloseTalentAtlas,false) end
        if RPAL.UI and RPAL.UI.customizationFrame and RPAL.CloseCustomization then
            pcall(RPAL.CloseCustomization,RPAL.UI.customizationFrame)
        end
    else
        if RPAL.ApplyTalentRewards then pcall(RPAL.ApplyTalentRewards) end
        if RPAL.ReconcileLevelRewards then pcall(RPAL.ReconcileLevelRewards) end
        if RPAL.ArmSeasonStartNotification then pcall(RPAL.ArmSeasonStartNotification) end
    end

    if RPAL.ApplyExperienceModeUI then pcall(RPAL.ApplyExperienceModeUI) end
    if RPAL.RefreshTalentButton then pcall(RPAL.RefreshTalentButton) end
    if RPAL.RefreshBookChrome then pcall(RPAL.RefreshBookChrome) end
    if RPAL.Refresh then pcall(RPAL.Refresh) end
    return true,mode
end

function RPAL.NewID(prefix)
    return (prefix or "id") .. "-" .. tostring(time()) .. "-" .. tostring(math.random(100000, 999999))
end

local function DefaultProfile()
    local playerName = UnitName("player") or ""
    return {
        title = "",
        firstName = playerName,
        surname = "",
        pronouns = "",
        guildRank = "",
        guildName = "",
        className = "",
        classColor = nil,
        race = "",
        customRaces = {},
        easyMode = false,

        age = "",
        birthplace = "",
        residence = "",
        height = "",
        bodyShape = "",
        weight = "",
        hairColor = "",
        relationship = "",
        commonlyFound = "",

        eyeMode = "single",
        eyeColor = { r = 0.29, g = 0.50, b = 0.84 },
        leftEyeColor = { r = 0.29, g = 0.50, b = 0.84 },
        rightEyeColor = { r = 0.29, g = 0.50, b = 0.84 },
        furColor = { r = 0.85, g = 0.85, b = 0.85 },

        worgen = false,
        kulTiran = false,
        maghar = false,
        au = false,
        mu = false,

        -- TRP3-driven Information layout.
        useTRPInformation = false,
        trpAdditionalFields = {},
        trpEyeText = "",
        trpEyeHex = "",
        trpImportedOnce = false,
        glances = {},

    }
end

local function EnsureProfile(p)
    local d = DefaultProfile()
    if type(p) ~= "table" then p = {} end
    for k, v in pairs(d) do
        if p[k] == nil then p[k] = Copy(v) end
    end
    if type(p.customRaces) ~= "table" then p.customRaces = {} end
    if type(p.trpAdditionalFields) ~= "table" then p.trpAdditionalFields = {} end
    if type(p.glances) ~= "table" then p.glances = {} end
    if type(p.useTRPInformation) ~= "boolean" then p.useTRPInformation = false end
    if type(p.trpImportedOnce) ~= "boolean" then p.trpImportedOnce = false end
    if p.race=="Maghar Orc" then p.race="Mag'har Orc" end
    -- Existing pre-1.3.73 characters that already use TRP Information must
    -- have imported before this flag existed. Do not show them the first-import CTA.
    if (p.useTRPInformation or #(p.trpAdditionalFields or {})>0) and not p.trpImportedOnce then p.trpImportedOnce = true end
    if p.useTRPInformation and (p.bodyShape==nil or p.bodyShape=="") and p.weight and p.weight~="" then
        p.bodyShape=p.weight
    end
    if p.classColor ~= nil and type(p.classColor) ~= "table" then p.classColor = nil end
    return p
end

-- ------------------------------------------------------------
-- Total RP 3 profile-scoped character state
-- ------------------------------------------------------------
-- A single WoW character can own several completely different TRP profiles.
-- The active TRP profile is therefore the identity boundary for every piece
-- of role-character data, including appearance ownership and loadout.  Only
-- deliberately account-wide systems (settings, Token wallet and Main choice)
-- remain in RPAL_DB.
local TRP_PROFILE_SCOPED_FIELDS={
    "profile","library","progress","favourites",
    "previousLibrary","previousProgress","previousFavourites",
    "statistics","survival","injuries","customization","cosmeticCollection",
    "skillSet","calendar","roleplay","rpCurrency","talents","memoirs","legacyCalibration","dmCampaigns","privacy",
}
local TRP_FALLBACK_PROFILE_ID="__wow_character__"

local function NewStatisticsState()
    return {
        version=3,score=0,totalEvents=0,counts={},recent={},scoreThrottle={},
        metPlayers={},charactersMet=0,campaignsJoined=0,joinedCampaignIDs={},progressionCurveVersion=RPAL.RENOWN_CURVE_VERSION or 4,
    }
end

local function NewCosmeticCollection()
    return {
        unlockedThemes={
            ["Ancient Book"]=true,
            ["Obsidian Crown"]=true,
        },
        unlockedCosmetics={
            portrait={Default=true},
            level={Default=true,["Initiate Seal"]=true},
            header={None=true},
            background={None=true},
            aura={None=true},
            ornament={Classic=true},
            effect={None=true},
            toast={Classic=true},
            tooltip={Classic=true},
            focus={Classic=true},
            eye={Default=true},
            platform={Default=true},
            animation={Stand=true,Wave=true},
        },
    }
end

local function EnsureCosmeticCollection(collection)
    collection=type(collection)=="table" and collection or NewCosmeticCollection()
    collection.unlockedThemes=type(collection.unlockedThemes)=="table" and collection.unlockedThemes or {}
    collection.unlockedThemes["Ancient Book"]=true
    collection.unlockedThemes["Obsidian Crown"]=true
    collection.unlockedCosmetics=type(collection.unlockedCosmetics)=="table" and collection.unlockedCosmetics or {}
    local defaults=NewCosmeticCollection().unlockedCosmetics
    for kind,ownedDefaults in pairs(defaults) do
        local owned=type(collection.unlockedCosmetics[kind])=="table" and collection.unlockedCosmetics[kind] or {}
        collection.unlockedCosmetics[kind]=owned
        for name,isOwned in pairs(ownedDefaults) do if isOwned then owned[name]=true end end
    end
    -- Retired Fall 2026 reward draft: remove its stale entitlements from every profile.
    local retired={
        header={"Copperleaf","Amber Canopy"},portrait={"Wayfarer's Wreath","Gloamwood Crown"},
        background={"Harvest Moon"},aura={"Harvest Ember","Foxfire Amber"},
        ornament={"Oakbound Ledger"},effect={"Falling Leaves","Bonfire Waltz"},
    }
    for kind,names in pairs(retired) do
        local owned=collection.unlockedCosmetics[kind]
        if type(owned)=="table" then for _,name in ipairs(names) do owned[name]=nil end end
    end
    collection.unlockedThemes["Harvest Hearth"]=nil
    collection.unlockedCosmetics.flourish=nil
    -- Maplefall naming migration: preserve legacy preview ownership while renaming the items.
    local maplefallAliases={
        portrait={["Wayfinder Frame"]="Maplefall Frame"},
        level={["Wayfinder Seal"]="Maplefall Seal"},
        header={["Wayfinder Crest"]="Maplefall Crest"},
        ornament={["Wayfinder Binding"]="Maplefall Binding"},
        effect={["Wayfinder Sparks"]="Maplefall Sparks"},
        tooltip={["Wayfinder Pointer"]="Maplefall Pointer"},
        focus={["Wayfinder Corners"]="Maplefall Corners"},
    }
    for kind,map in pairs(maplefallAliases) do
        local owned=collection.unlockedCosmetics[kind]
        if type(owned)=="table" then
            for oldName,newName in pairs(map) do if owned[oldName] then owned[newName]=true end;owned[oldName]=nil end
        end
    end
    -- Fall visual cleanup: move legacy preview ownership onto the final native-style replacements.
    local replacements={
        portrait={["Wayfarer Veil"]="Maplefall Frame",["Blueglass Halo"]="Moonlit Frame",["Ember Archive"]="Ember Frame",["Ravenwatch"]="Raven Frame",["Sunset Reliquary"]="Autumn Frame"},
        level={["Autumn Leaf"]="Maplefall Seal",["Northstar"]="Northstar Seal",["Archive Tree"]="Archivist Seal",["Wayglass"]="Wayglass Seal",["Wolfbound"]="Wolf Seal"},
    }
    for kind,map in pairs(replacements) do
        local owned=collection.unlockedCosmetics[kind]
        if type(owned)=="table" then for oldName,newName in pairs(map) do if owned[oldName] then owned[newName]=true end;owned[oldName]=nil end end
    end
    return collection
end

local function EnsureCharacterCustomization(customization,collection)
    customization=type(customization)=="table" and customization or {}
    collection=EnsureCosmeticCollection(collection)
    if customization.portraitFrame==nil then customization.portraitFrame="Default" end
    local maplefallSelections={
        portraitFrame={["Wayfinder Frame"]="Maplefall Frame"},
        levelBadge={["Wayfinder Seal"]="Maplefall Seal"},
        headerDecoration={["Wayfinder Crest"]="Maplefall Crest"},
        uiOrnament={["Wayfinder Binding"]="Maplefall Binding"},
        effectPack={["Wayfinder Sparks"]="Maplefall Sparks"},
        tooltipTreatment={["Wayfinder Pointer"]="Maplefall Pointer"},
        focusMark={["Wayfinder Corners"]="Maplefall Corners"},
    }
    for field,map in pairs(maplefallSelections) do
        local value=customization[field]
        if map[value] then customization[field]=map[value] end
    end
    local retiredPortraitSelection={["Wayfarer Veil"]="Maplefall Frame",["Blueglass Halo"]="Moonlit Frame",["Ember Archive"]="Ember Frame",["Ravenwatch"]="Raven Frame",["Sunset Reliquary"]="Autumn Frame"}
    customization.portraitFrame=retiredPortraitSelection[customization.portraitFrame] or customization.portraitFrame
    local retiredBadgeSelection={["Autumn Leaf"]="Maplefall Seal",["Northstar"]="Northstar Seal",["Archive Tree"]="Archivist Seal",["Wayglass"]="Wayglass Seal",["Wolfbound"]="Wolf Seal"}
    customization.levelBadge=retiredBadgeSelection[customization.levelBadge] or customization.levelBadge
    local validBadges={
        ["Initiate Seal"]=true,["Verdant Mark"]=true,["Azure Sigil"]=true,
        ["Amethyst Crest"]=true,["Rose Emblem"]=true,["Sunforged Seal"]=true,
        ["Crimson Legend"]=true,["Legacy Chronicle"]=true,["Captain's Radiance"]=true,["Open Beta Crest"]=true,
        ["Maplefall Seal"]=true,["Northstar Seal"]=true,["Archivist Seal"]=true,["Wayglass Seal"]=true,["Wolf Seal"]=true,
    }
    if not validBadges[customization.levelBadge] then customization.levelBadge="Initiate Seal" end
    if customization.headerDecoration==nil or customization.headerDecoration=="Alliance" or customization.headerDecoration=="Fel" or customization.headerDecoration=="Runic" then
        customization.headerDecoration="None"
    end
    if customization.background==nil then customization.background="None" end
    if customization.profileAura==nil then customization.profileAura="None" end
    if customization.uiOrnament==nil then customization.uiOrnament="Classic" end
    if customization.effectPack==nil then customization.effectPack="None" end
    if customization.toastFrame==nil then customization.toastFrame="Classic" end
    if customization.tooltipTreatment==nil then customization.tooltipTreatment="Classic" end
    if customization.focusMark==nil then customization.focusMark="Classic" end
    if customization.odynEye==nil then customization.odynEye="Default" end
    customization.profileFlourish=nil
    if customization.platformStyle==nil then customization.platformStyle="Default" end
    if customization.characterAnimation==nil then customization.characterAnimation="Stand" end
    if type(customization.characterAnimations)~="table" then
        customization.characterAnimations={}
        for animationName,isOwned in pairs(collection.unlockedCosmetics.animation or {}) do
            if isOwned and animationName~="Stand" then customization.characterAnimations[animationName]=true end
        end
    end
    if customization.themeName==nil then
        customization.themeName=(type(RPAL_DB)=="table" and RPAL_DB.settings and RPAL_DB.settings.theme) or "Obsidian Crown"
    end
    if customization.themeManualOverride==nil then
        local setting=type(RPAL_DB)=="table" and RPAL_DB.settings and RPAL_DB.settings.themeManualOverride
        customization.themeManualOverride=setting==nil and true or (setting and true or false)
    end
    if customization.themeName=="Harvest Hearth" then customization.themeName="Obsidian Crown" end
    if customization.headerDecoration=="Copperleaf" or customization.headerDecoration=="Amber Canopy" then customization.headerDecoration="None" end
    if customization.portraitFrame=="Wayfarer's Wreath" or customization.portraitFrame=="Gloamwood Crown" then customization.portraitFrame="Default" end
    if customization.background=="Harvest Moon" then customization.background="None" end
    if customization.profileAura=="Harvest Ember" or customization.profileAura=="Foxfire Amber" then customization.profileAura="None" end
    if customization.uiOrnament=="Oakbound Ledger" then customization.uiOrnament="Classic" end
    if customization.effectPack=="Falling Leaves" or customization.effectPack=="Bonfire Waltz" then customization.effectPack="None" end
    return customization
end

local function EnsureTRPProfileScopeState(scope)
    scope=type(scope)=="table" and scope or {}
    scope.version=2
    scope.profile=EnsureProfile(scope.profile)
    scope.library=type(scope.library)=="table" and scope.library or Copy(RPAL.DEFAULT_LIBRARY or {})
    scope.progress=type(scope.progress)=="table" and scope.progress or {}
    scope.favourites=type(scope.favourites)=="table" and scope.favourites or {}
    scope.statistics=type(scope.statistics)=="table" and scope.statistics or NewStatisticsState()
    local statistics=scope.statistics
    statistics.version=3
    statistics.score=math.max(0,math.floor(tonumber(statistics.score) or 0))
    statistics.totalEvents=math.max(0,math.floor(tonumber(statistics.totalEvents) or 0))
    statistics.counts=type(statistics.counts)=="table" and statistics.counts or {}
    statistics.recent=type(statistics.recent)=="table" and statistics.recent or {}
    statistics.scoreThrottle=type(statistics.scoreThrottle)=="table" and statistics.scoreThrottle or {}
    statistics.metPlayers=type(statistics.metPlayers)=="table" and statistics.metPlayers or {}
    statistics.charactersMet=math.max(0,math.floor(tonumber(statistics.charactersMet) or 0))
    statistics.joinedCampaignIDs=type(statistics.joinedCampaignIDs)=="table" and statistics.joinedCampaignIDs or {}
    statistics.campaignsJoined=math.max(0,math.floor(tonumber(statistics.campaignsJoined) or 0))
    if RPAL.MigrateRenownCurve then RPAL.MigrateRenownCurve(statistics) end
    scope.survival=type(scope.survival)=="table" and scope.survival or {}
    scope.survival.food=math.max(0,math.min(100,tonumber(scope.survival.food) or 100))
    scope.survival.water=math.max(0,math.min(100,tonumber(scope.survival.water) or 100))
    scope.survival.lastUpdate=nil
    scope.injuries=type(scope.injuries)=="table" and scope.injuries or {version=2,nextID=0,nextGroupID=0,items={},groups={}}
    scope.injuries.items=type(scope.injuries.items)=="table" and scope.injuries.items or {}
    scope.injuries.groups=type(scope.injuries.groups)=="table" and scope.injuries.groups or {}
    scope.skillSet=type(scope.skillSet)=="table" and scope.skillSet or {values={}}
    scope.skillSet.values=type(scope.skillSet.values)=="table" and scope.skillSet.values or {}
    scope.calendar=type(scope.calendar)=="table" and scope.calendar or {events={},remindersEnabled=true}
    scope.calendar.events=type(scope.calendar.events)=="table" and scope.calendar.events or {}
    if scope.calendar.remindersEnabled==nil then scope.calendar.remindersEnabled=true end
    scope.roleplay=type(scope.roleplay)=="table" and scope.roleplay or {version=2,scenes={},pausedScenes={},bonds={},moments={},arcs={},spark={enabled=true}}
    scope.rpCurrency=type(scope.rpCurrency)=="table" and scope.rpCurrency or {version=1,pouch=0,bank=0,receivedTransfers={},log={}}
    scope.rpCurrency.version=1
    scope.rpCurrency.pouch=math.max(0,math.floor(tonumber(scope.rpCurrency.pouch) or 0))
    scope.rpCurrency.bank=math.max(0,math.floor(tonumber(scope.rpCurrency.bank) or 0))
    scope.rpCurrency.receivedTransfers=type(scope.rpCurrency.receivedTransfers)=="table" and scope.rpCurrency.receivedTransfers or {}
    scope.rpCurrency.log=type(scope.rpCurrency.log)=="table" and scope.rpCurrency.log or {}
    scope.talents=type(scope.talents)=="table" and scope.talents or {}
    scope.memoirs=type(scope.memoirs)=="table" and scope.memoirs or {unlocked={}}
    scope.memoirs.unlocked=type(scope.memoirs.unlocked)=="table" and scope.memoirs.unlocked or {}
    scope.legacyCalibration=type(scope.legacyCalibration)=="table" and scope.legacyCalibration or {completed=false}
    scope.dmCampaigns=type(scope.dmCampaigns)=="table" and scope.dmCampaigns or {records={},order={}}
    scope.dmCampaigns.records=type(scope.dmCampaigns.records)=="table" and scope.dmCampaigns.records or {}
    scope.dmCampaigns.order=type(scope.dmCampaigns.order)=="table" and scope.dmCampaigns.order or {}
    scope.privacy=type(scope.privacy)=="table" and scope.privacy or {}
    if scope.privacy.hidePublicProfile==nil then scope.privacy.hidePublicProfile=false end
    if scope.privacy.hideTRPCompanion==nil then scope.privacy.hideTRPCompanion=false end
    -- Publishing into TRP's authored Additional Information is explicit opt-in.
    -- ODYN's own tooltip bridge remains controlled separately by hideTRPCompanion.
    if scope.privacy.showTRPAdditionalInfo==nil then scope.privacy.showTRPAdditionalInfo=false end
    scope.cosmeticCollection=EnsureCosmeticCollection(scope.cosmeticCollection)
    scope.customization=EnsureCharacterCustomization(scope.customization,scope.cosmeticCollection)
    return scope
end

local function NewTRPProfileScope(profileID)
    return EnsureTRPProfileScopeState({
        version=2,profileID=tostring(profileID or ""),createdAt=time(),lastUsedAt=time(),
    })
end

function RPAL.GetActiveTRPProfileID()
    local api=TRP3_API and TRP3_API.profile
    if type(api)~="table" or type(api.getPlayerCurrentProfileID)~="function" then return nil end
    local ok,value=pcall(api.getPlayerCurrentProfileID)
    if not ok or value==nil then return nil end
    value=tostring(value):gsub("^%s+",""):gsub("%s+$","")
    if value=="" or #value>160 then return nil end
    return value
end

-- Manual Total RP 3 profile links ------------------------------------------------
-- A physical TRP profile can point at an existing ODYN progression scope. This
-- allows several TRP identities on the same WoW character to intentionally share
-- one ODYN history/progression record without merging or deleting any stored data.
-- Removing a link simply makes that TRP profile use its own preserved scope again.
local function EnsureTRPProfileBindings()
    if type(RPAL_CHAR_DB)~="table" then return {} end
    RPAL_CHAR_DB.trpProfileBindings=type(RPAL_CHAR_DB.trpProfileBindings)=="table" and RPAL_CHAR_DB.trpProfileBindings or {}
    local bindings=RPAL_CHAR_DB.trpProfileBindings
    for source,target in pairs(bindings) do
        if type(source)~="string" or source=="" or type(target)~="string" or target=="" or target==TRP_FALLBACK_PROFILE_ID then
            bindings[source]=nil
        end
    end
    return bindings
end

function RPAL.GetTRPProfileBindings()
    return EnsureTRPProfileBindings()
end

function RPAL.GetBoundTRPScopeID(profileID)
    profileID=tostring(profileID or ""):gsub("^%s+",""):gsub("%s+$","")
    if profileID=="" then return TRP_FALLBACK_PROFILE_ID end
    local target=EnsureTRPProfileBindings()[profileID]
    local scopes=type(RPAL_CHAR_DB)=="table" and RPAL_CHAR_DB.trpProfileScopes
    if target and type(scopes)=="table" and type(scopes[target])=="table" then return target end
    return profileID
end

function RPAL.LinkTRPProfileToScope(profileID,scopeID)
    profileID=tostring(profileID or ""):gsub("^%s+",""):gsub("%s+$","")
    scopeID=tostring(scopeID or ""):gsub("^%s+",""):gsub("%s+$","")
    if profileID=="" then return false,"Invalid TRP profile ID." end
    if scopeID=="" or scopeID==TRP_FALLBACK_PROFILE_ID then return false,"Choose a real ODYN profile scope first." end
    if profileID==scopeID then
        -- Linking a profile to its own native scope is equivalent to no link.
        EnsureTRPProfileBindings()[profileID]=nil
        if RPAL.GetActiveTRPProfileID and RPAL.GetActiveTRPProfileID()==profileID and RPAL.SyncTRPProfileScope then RPAL.SyncTRPProfileScope(true) end
        return true,scopeID
    end
    local scopes=type(RPAL_CHAR_DB)=="table" and RPAL_CHAR_DB.trpProfileScopes
    if type(scopes)~="table" or type(scopes[scopeID])~="table" then return false,"The selected ODYN profile scope does not exist." end
    EnsureTRPProfileBindings()[profileID]=scopeID
    if RPAL.GetActiveTRPProfileID and RPAL.GetActiveTRPProfileID()==profileID and RPAL.SyncTRPProfileScope then
        RPAL.SyncTRPProfileScope(true)
    end
    return true,scopeID
end

function RPAL.LinkTRPProfileToCurrentScope(profileID)
    local scopeID=RPAL._activeTRPProfileScopeID
    if not scopeID or scopeID==TRP_FALLBACK_PROFILE_ID then
        local current=RPAL.GetActiveTRPProfileID and RPAL.GetActiveTRPProfileID()
        scopeID=current and RPAL.GetBoundTRPScopeID(current) or nil
    end
    return RPAL.LinkTRPProfileToScope(profileID,scopeID)
end

function RPAL.UnlinkTRPProfile(profileID)
    profileID=tostring(profileID or ""):gsub("^%s+",""):gsub("%s+$","")
    if profileID=="" then return false,"Invalid TRP profile ID." end
    local bindings=EnsureTRPProfileBindings()
    local old=bindings[profileID]
    if not old then return false,"This TRP profile is not manually linked." end
    bindings[profileID]=nil
    if RPAL.GetActiveTRPProfileID and RPAL.GetActiveTRPProfileID()==profileID and RPAL.SyncTRPProfileScope then
        RPAL.SyncTRPProfileScope(true)
    end
    return true,old
end

local function ResolveProgressionProfileID()
    local detected=RPAL.GetActiveTRPProfileID()
    if detected then return RPAL.GetBoundTRPScopeID(detected) end
    -- TRP can briefly return no profile while its cache reloads. Once a real
    -- identity has been bound in this login session, keep it active rather
    -- than flashing to a blank fallback profile and back again.
    if RPAL._activeTRPProfileScopeRoot==RPAL_CHAR_DB
        and RPAL._activeTRPProfileScopeID and RPAL._activeTRPProfileScopeID~=TRP_FALLBACK_PROFILE_ID then
        return RPAL._activeTRPProfileScopeID
    end
    return TRP_FALLBACK_PROFILE_ID
end

local function BindTRPProfileScope(scope)
    if type(scope)~="table" or type(RPAL_CHAR_DB)~="table" then return false end
    scope=EnsureTRPProfileScopeState(scope)
    for _,field in ipairs(TRP_PROFILE_SCOPED_FIELDS) do RPAL_CHAR_DB[field]=scope[field] end
    RPAL.char=RPAL_CHAR_DB
    return true
end

function RPAL.SyncTRPProfileScope(force)
    if type(RPAL_CHAR_DB)~="table" or not RPAL.char then return false,"Character data is not ready." end
    local checkNow=(GetTime and GetTime()) or 0
    if not force and checkNow>0 and RPAL._lastTRPProfileScopeCheckAt and checkNow-RPAL._lastTRPProfileScopeCheckAt<0.35 then
        return false,"throttled"
    end
    if checkNow>0 then RPAL._lastTRPProfileScopeCheckAt=checkNow end
    local sourceProfileID=RPAL.GetActiveTRPProfileID() or TRP_FALLBACK_PROFILE_ID
    local profileID=ResolveProgressionProfileID()
    local sameRoot=RPAL._activeTRPProfileScopeRoot==RPAL_CHAR_DB
    RPAL_CHAR_DB.trpProfileScopes=type(RPAL_CHAR_DB.trpProfileScopes)=="table" and RPAL_CHAR_DB.trpProfileScopes or {}
    local scopes=RPAL_CHAR_DB.trpProfileScopes

    -- A few mature APIs replace a scoped table instead of mutating it. Save
    -- the active aliases before rebinding so those replacements remain owned
    -- by the profile that made them.
    local activeID=RPAL._activeTRPProfileScopeID
    local activeSourceID=RPAL._activeTRPSourceProfileID
    if sameRoot and activeID and type(scopes[activeID])=="table" then
        for _,field in ipairs(TRP_PROFILE_SCOPED_FIELDS) do scopes[activeID][field]=RPAL_CHAR_DB[field] end
    end
    if not force and sameRoot and activeID==profileID and activeSourceID==sourceProfileID then return false,"unchanged" end

    local scope=scopes[profileID]

    -- First migration claims the existing character-wide progress for the TRP
    -- profile that is active right now.  Every later profile starts clean.
    if type(scope)~="table" then
        if not RPAL_CHAR_DB.trpProfileScopeMigrationOwner then
            local fallback=scopes[TRP_FALLBACK_PROFILE_ID]
            if profileID~=TRP_FALLBACK_PROFILE_ID and type(fallback)=="table" and fallback.awaitingTRPIdentity then
                scope=fallback;scope.awaitingTRPIdentity=nil;scope.profileID=profileID
                scopes[TRP_FALLBACK_PROFILE_ID]=nil
            else
                scope={version=2,profileID=profileID,createdAt=time(),lastUsedAt=time()}
                for _,field in ipairs(TRP_PROFILE_SCOPED_FIELDS) do
                    if type(RPAL_CHAR_DB[field])=="table" then scope[field]=RPAL_CHAR_DB[field] end
                end
                -- Customization ownership used to be account-wide. The first
                -- role profile receives a copy; every later profile starts its
                -- own collection.
                if not scope.cosmeticCollection and type(RPAL_DB)=="table" and type(RPAL_DB.customization)=="table" then
                    scope.cosmeticCollection=Copy(RPAL_DB.customization)
                end
            end
            if profileID==TRP_FALLBACK_PROFILE_ID then scope.awaitingTRPIdentity=true
            else RPAL_CHAR_DB.trpProfileScopeMigrationOwner=profileID end
        else
            scope=NewTRPProfileScope(profileID)
        end
        scopes[profileID]=scope
    end

    local previous=RPAL._activeTRPProfileScopeID
    local previousSource=RPAL._activeTRPSourceProfileID
    scope.lastUsedAt=time();RPAL_CHAR_DB.activeTRPProfileID=sourceProfileID;RPAL_CHAR_DB.activeODYNProfileScopeID=profileID
    BindTRPProfileScope(scope)
    RPAL._activeTRPProfileScopeID=profileID
    RPAL._activeTRPSourceProfileID=sourceProfileID
    RPAL._activeTRPProfileScopeRoot=RPAL_CHAR_DB
    if RPAL.ApplyFall2026StoreRefundMigration then pcall(RPAL.ApplyFall2026StoreRefundMigration) end
    if RPAL.ApplyODYNEyeProgressionMigration then pcall(RPAL.ApplyODYNEyeProgressionMigration) end
    if RPAL.ReconcileLevelRewards then RPAL.ReconcileLevelRewards() end

    if (previous and previous~=profileID) or (previousSource and previousSource~=sourceProfileID) then
        -- Runtime-only trackers must never leak between two roleplay identities,
        -- even when two TRP profiles intentionally share one ODYN progression scope.
        RPAL._talentSession=nil;RPAL._sceneRuntimeMark=nil;RPAL._lastTRPBondSync=nil
        RPAL._lastSessionTRPStatus=nil;RPAL._oocSessionPrompted=false
        if RPAL.OnTRPProfileScopeChanged then RPAL.OnTRPProfileScopeChanged(profileID,previous,sourceProfileID,previousSource) end
    end
    return true,profileID,previous,sourceProfileID,previousSource
end

function RPAL.GetCurrentTRPProfileScope()
    local id=ResolveProgressionProfileID()
    local scopes=type(RPAL_CHAR_DB)=="table" and RPAL_CHAR_DB.trpProfileScopes
    local source=RPAL.GetActiveTRPProfileID and RPAL.GetActiveTRPProfileID() or RPAL._activeTRPSourceProfileID
    return id and type(scopes)=="table" and scopes[id] or nil,id,source
end

function RPAL.GetCurrentTRPSourceProfileID()
    return (RPAL.GetActiveTRPProfileID and RPAL.GetActiveTRPProfileID()) or RPAL._activeTRPSourceProfileID
end
function RPAL.GetProfilePrivacy()
    if not RPAL.char then return {hidePublicProfile=false,hideTRPCompanion=false,showTRPAdditionalInfo=false} end
    RPAL.char.privacy=type(RPAL.char.privacy)=="table" and RPAL.char.privacy or {}
    if RPAL.char.privacy.hidePublicProfile==nil then RPAL.char.privacy.hidePublicProfile=false end
    if RPAL.char.privacy.hideTRPCompanion==nil then RPAL.char.privacy.hideTRPCompanion=false end
    if RPAL.char.privacy.showTRPAdditionalInfo==nil then RPAL.char.privacy.showTRPAdditionalInfo=false end
    local scope=RPAL.GetCurrentTRPProfileScope and RPAL.GetCurrentTRPProfileScope()
    if scope then scope.privacy=RPAL.char.privacy end
    return RPAL.char.privacy
end

function RPAL.SetTRPProfileScopedField(field,value)
    if not RPAL.char then return false end
    RPAL.char[field]=value
    local scope=RPAL.GetCurrentTRPProfileScope and RPAL.GetCurrentTRPProfileScope()
    if scope then scope[field]=value end
    return true
end
function RPAL.GetCosmeticCollection()
    if not RPAL.char then return EnsureCosmeticCollection(nil) end
    RPAL.char.cosmeticCollection=EnsureCosmeticCollection(RPAL.char.cosmeticCollection)
    local scope=RPAL.GetCurrentTRPProfileScope and RPAL.GetCurrentTRPProfileScope()
    if scope then scope.cosmeticCollection=RPAL.char.cosmeticCollection end
    return RPAL.char.cosmeticCollection
end
RPAL.TRP_FALLBACK_PROFILE_ID=TRP_FALLBACK_PROFILE_ID

local function FindCategoryByID(library, id)
    for _, c in ipairs(library or {}) do
        if c.id == id then return c end
    end
end

local function FindBadgeByID(category, id)
    if not category then return end
    for _, b in ipairs(category.badges or {}) do
        if b.id == id then return b end
    end
end

function RPAL.MergeTemplate()
    RPAL_DB.library = RPAL_DB.library or {}
    for _, builtinCategory in ipairs(RPAL.DEFAULT_LIBRARY) do
        local category = FindCategoryByID(RPAL_DB.library, builtinCategory.id)
        if not category then
            table.insert(RPAL_DB.library, Copy(builtinCategory))
        else
            category.badges = category.badges or {}
            for _, builtinBadge in ipairs(builtinCategory.badges or {}) do
                if not FindBadgeByID(category, builtinBadge.id) then
                    table.insert(category.badges, Copy(builtinBadge))
                end
            end
        end
    end
    RPAL_DB.templateVersion = RPAL.TEMPLATE_VERSION
end

function RPAL.InitializeDB()
    if type(RPAL_DB) ~= "table" then RPAL_DB = {} end
    if type(RPAL_CHAR_DB) ~= "table" then RPAL_CHAR_DB = {} end

    -- Account-wide book.
    RPAL_DB.accountLibrary = RPAL_DB.accountLibrary or {}
    RPAL_DB.accountProgress = RPAL_DB.accountProgress or {}
    RPAL_DB.accountFavourites = RPAL_DB.accountFavourites or {}
    RPAL_DB.accountPreviousLibrary = RPAL_DB.accountPreviousLibrary

    -- Per-character book + character profile.
    RPAL_CHAR_DB.profile = EnsureProfile(RPAL_CHAR_DB.profile)
    RPAL_CHAR_DB.library = RPAL_CHAR_DB.library or nil
    RPAL_CHAR_DB.progress = RPAL_CHAR_DB.progress or {}
    RPAL_CHAR_DB.favourites = RPAL_CHAR_DB.favourites or {}
    RPAL_CHAR_DB.previousLibrary = RPAL_CHAR_DB.previousLibrary
    RPAL_CHAR_DB.statistics = RPAL_CHAR_DB.statistics or {
        version=3, score=0, totalEvents=0, counts={}, recent={}, scoreThrottle={},
        metPlayers={}, charactersMet=0, campaignsJoined=0, joinedCampaignIDs={}, progressionCurveVersion=RPAL.RENOWN_CURVE_VERSION or 4
    }
    RPAL_CHAR_DB.statistics.version = tonumber(RPAL_CHAR_DB.statistics.version) or 1
    RPAL_CHAR_DB.statistics.score = tonumber(RPAL_CHAR_DB.statistics.score) or 0
    RPAL_CHAR_DB.statistics.totalEvents = tonumber(RPAL_CHAR_DB.statistics.totalEvents) or 0
    RPAL_CHAR_DB.statistics.counts = RPAL_CHAR_DB.statistics.counts or {}
    RPAL_CHAR_DB.statistics.recent = RPAL_CHAR_DB.statistics.recent or {}
    RPAL_CHAR_DB.statistics.scoreThrottle = RPAL_CHAR_DB.statistics.scoreThrottle or {}
    RPAL_CHAR_DB.statistics.metPlayers = RPAL_CHAR_DB.statistics.metPlayers or {}
    RPAL_CHAR_DB.statistics.charactersMet = tonumber(RPAL_CHAR_DB.statistics.charactersMet) or 0
    RPAL_CHAR_DB.statistics.joinedCampaignIDs = RPAL_CHAR_DB.statistics.joinedCampaignIDs or {}
    RPAL_CHAR_DB.statistics.campaignsJoined = tonumber(RPAL_CHAR_DB.statistics.campaignsJoined) or 0
    if RPAL_CHAR_DB.statistics.version < 3 then RPAL_CHAR_DB.statistics.version=3 end

    -- Character-specific survival needs.
    RPAL_CHAR_DB.survival = RPAL_CHAR_DB.survival or {}
    RPAL_CHAR_DB.survival.food = math.max(0,math.min(100,tonumber(RPAL_CHAR_DB.survival.food) or 100))
    RPAL_CHAR_DB.survival.water = math.max(0,math.min(100,tonumber(RPAL_CHAR_DB.survival.water) or 100))
    -- Session-only decay: do NOT use a persisted wall-clock timestamp.
    RPAL_CHAR_DB.survival.lastUpdate = nil

    -- Character injury record: positions, grouping and recovery timers are
    -- intentionally per-character so public inspect always reflects this RP character.
    RPAL_CHAR_DB.injuries = RPAL_CHAR_DB.injuries or {}
    RPAL_CHAR_DB.injuries.version = tonumber(RPAL_CHAR_DB.injuries.version) or 1
    RPAL_CHAR_DB.injuries.nextID = tonumber(RPAL_CHAR_DB.injuries.nextID) or 0
    RPAL_CHAR_DB.injuries.nextGroupID = tonumber(RPAL_CHAR_DB.injuries.nextGroupID) or 0
    RPAL_CHAR_DB.injuries.items = RPAL_CHAR_DB.injuries.items or {}
    RPAL_CHAR_DB.injuries.groups = RPAL_CHAR_DB.injuries.groups or {}

    -- Character / TRP-profile scoped roleplay currency. This is intentionally
    -- independent from the real World of Warcraft wallet.
    RPAL_CHAR_DB.rpCurrency = type(RPAL_CHAR_DB.rpCurrency)=="table" and RPAL_CHAR_DB.rpCurrency or {version=1,pouch=0,bank=0,receivedTransfers={},log={}}
    RPAL_CHAR_DB.rpCurrency.version=1
    RPAL_CHAR_DB.rpCurrency.pouch=math.max(0,math.floor(tonumber(RPAL_CHAR_DB.rpCurrency.pouch) or 0))
    RPAL_CHAR_DB.rpCurrency.bank=math.max(0,math.floor(tonumber(RPAL_CHAR_DB.rpCurrency.bank) or 0))
    RPAL_CHAR_DB.rpCurrency.receivedTransfers=type(RPAL_CHAR_DB.rpCurrency.receivedTransfers)=="table" and RPAL_CHAR_DB.rpCurrency.receivedTransfers or {}
    RPAL_CHAR_DB.rpCurrency.log=type(RPAL_CHAR_DB.rpCurrency.log)=="table" and RPAL_CHAR_DB.rpCurrency.log or {}

    -- Migration from the old one-book/account-wide layout.
    -- Old data semantically behaved like character data, so preserve it there.
    if type(RPAL_DB.profile)=="table" then
        RPAL_CHAR_DB.profile = EnsureProfile(RPAL_DB.profile)
        RPAL_DB.profile = nil
    end

    if not RPAL_CHAR_DB.library then
        if type(RPAL_DB.library)=="table" and #RPAL_DB.library>0 then
            RPAL_CHAR_DB.library = RPAL.Copy(RPAL_DB.library)
        else
            RPAL_CHAR_DB.library = RPAL.Copy(RPAL.DEFAULT_LIBRARY)
        end
    end

    if type(RPAL_DB.progress)=="table" and next(RPAL_DB.progress) and not next(RPAL_CHAR_DB.progress) then
        RPAL_CHAR_DB.progress = RPAL_DB.progress
    end

    if type(RPAL_DB.favourites)=="table" and next(RPAL_DB.favourites) and not next(RPAL_CHAR_DB.favourites) then
        RPAL_CHAR_DB.favourites = RPAL_DB.favourites
    end

    RPAL_DB.library = nil
    RPAL_DB.progress = nil
    RPAL_DB.favourites = nil
    RPAL_DB.profile = nil

    -- One-time scoped-book initialization.
    -- q2 could create an empty Account book; seed both books with the basic
    -- category template once when upgrading to the scoped-book system.
    if tonumber(RPAL_DB.scopeBooksVersion or 0) < 2 then
        if type(RPAL_CHAR_DB.library)~="table" or #RPAL_CHAR_DB.library==0 then
            RPAL_CHAR_DB.library=RPAL.Copy(RPAL.DEFAULT_LIBRARY)
        end

        if type(RPAL_DB.accountLibrary)~="table" or #RPAL_DB.accountLibrary==0 then
            RPAL_DB.accountLibrary=RPAL.Copy(RPAL.DEFAULT_ACCOUNT_LIBRARY or RPAL.DEFAULT_LIBRARY)
        end

        RPAL_DB.scopeBooksVersion=2
    end

    -- The Token wallet remains account-wide. RPAL_DB.customization is retained
    -- only as a migration source for the first profile-scoped collection.
    RPAL_DB.currency = RPAL_DB.currency or {}
    RPAL_DB.currency.crests = math.max(0,math.floor(tonumber(RPAL_DB.currency.crests) or 0))

    RPAL_DB.customization = RPAL_DB.customization or {}
    RPAL_DB.customization.unlockedThemes = RPAL_DB.customization.unlockedThemes or {}
    RPAL_DB.customization.unlockedThemes["Ancient Book"] = true
    RPAL_DB.customization.unlockedThemes["Obsidian Crown"] = true

    -- Legacy unlock ownership (migrated into the first active TRP profile).
    RPAL_DB.customization.unlockedCosmetics = RPAL_DB.customization.unlockedCosmetics or {}
    RPAL_DB.customization.unlockedCosmetics.portrait = RPAL_DB.customization.unlockedCosmetics.portrait or {Default=true}
    RPAL_DB.customization.unlockedCosmetics.level = RPAL_DB.customization.unlockedCosmetics.level or {Default=true}
    RPAL_DB.customization.unlockedCosmetics.level["Initiate Seal"] = true
    RPAL_DB.customization.unlockedCosmetics.header = RPAL_DB.customization.unlockedCosmetics.header or {None=true}
    RPAL_DB.customization.unlockedCosmetics.background = RPAL_DB.customization.unlockedCosmetics.background or {None=true}
    RPAL_DB.customization.unlockedCosmetics.aura = RPAL_DB.customization.unlockedCosmetics.aura or {None=true}
    RPAL_DB.customization.unlockedCosmetics.ornament = RPAL_DB.customization.unlockedCosmetics.ornament or {Classic=true}
    RPAL_DB.customization.unlockedCosmetics.effect = RPAL_DB.customization.unlockedCosmetics.effect or {None=true}
    RPAL_DB.customization.unlockedCosmetics.toast = RPAL_DB.customization.unlockedCosmetics.toast or {Classic=true}
    RPAL_DB.customization.unlockedCosmetics.tooltip = RPAL_DB.customization.unlockedCosmetics.tooltip or {Classic=true}
    RPAL_DB.customization.unlockedCosmetics.focus = RPAL_DB.customization.unlockedCosmetics.focus or {Classic=true}
    RPAL_DB.customization.unlockedCosmetics.eye = RPAL_DB.customization.unlockedCosmetics.eye or {Default=true}
    RPAL_DB.customization.unlockedCosmetics.flourish=nil
    RPAL_DB.customization.unlockedCosmetics.platform = RPAL_DB.customization.unlockedCosmetics.platform or {Default=true}
    RPAL_DB.customization.unlockedCosmetics.animation = RPAL_DB.customization.unlockedCosmetics.animation or {Stand=true,Wave=true}
    -- Legacy account-wide collection compatibility for the pre-Maplefall preview build.
    local legacyMaplefallOwnership={
        portrait={["Wayfinder Frame"]="Maplefall Frame"},level={["Wayfinder Seal"]="Maplefall Seal"},
        header={["Wayfinder Crest"]="Maplefall Crest"},ornament={["Wayfinder Binding"]="Maplefall Binding"},
        effect={["Wayfinder Sparks"]="Maplefall Sparks"},tooltip={["Wayfinder Pointer"]="Maplefall Pointer"},
        focus={["Wayfinder Corners"]="Maplefall Corners"},
    }
    for kind,map in pairs(legacyMaplefallOwnership) do
        local owned=RPAL_DB.customization.unlockedCosmetics[kind]
        if type(owned)=="table" then
            for oldName,newName in pairs(map) do if owned[oldName] then owned[newName]=true end;owned[oldName]=nil end
        end
    end

    -- Equipped appearance is character-specific, including the selected theme.
    RPAL_CHAR_DB.customization = RPAL_CHAR_DB.customization or {}
    if RPAL_CHAR_DB.customization.portraitFrame==nil then RPAL_CHAR_DB.customization.portraitFrame="Default" end
    local legacyMaplefallSelections={
        portraitFrame={["Wayfinder Frame"]="Maplefall Frame"},levelBadge={["Wayfinder Seal"]="Maplefall Seal"},
        headerDecoration={["Wayfinder Crest"]="Maplefall Crest"},uiOrnament={["Wayfinder Binding"]="Maplefall Binding"},
        effectPack={["Wayfinder Sparks"]="Maplefall Sparks"},tooltipTreatment={["Wayfinder Pointer"]="Maplefall Pointer"},
        focusMark={["Wayfinder Corners"]="Maplefall Corners"},
    }
    for field,map in pairs(legacyMaplefallSelections) do local value=RPAL_CHAR_DB.customization[field];if map[value] then RPAL_CHAR_DB.customization[field]=map[value] end end
    local oldPortraitMap={["Wayfarer Veil"]="Maplefall Frame",["Blueglass Halo"]="Moonlit Frame",["Ember Archive"]="Ember Frame",["Ravenwatch"]="Raven Frame",["Sunset Reliquary"]="Autumn Frame"}
    RPAL_CHAR_DB.customization.portraitFrame=oldPortraitMap[RPAL_CHAR_DB.customization.portraitFrame] or RPAL_CHAR_DB.customization.portraitFrame
    local oldBadgeMap={["Autumn Leaf"]="Maplefall Seal",["Northstar"]="Northstar Seal",["Archive Tree"]="Archivist Seal",["Wayglass"]="Wayglass Seal",["Wolfbound"]="Wolf Seal"}
    RPAL_CHAR_DB.customization.levelBadge=oldBadgeMap[RPAL_CHAR_DB.customization.levelBadge] or RPAL_CHAR_DB.customization.levelBadge
    local validLevelBadges={
        ["Initiate Seal"]=true,["Verdant Mark"]=true,["Azure Sigil"]=true,
        ["Amethyst Crest"]=true,["Rose Emblem"]=true,["Sunforged Seal"]=true,
        ["Crimson Legend"]=true,["Legacy Chronicle"]=true,["Captain's Radiance"]=true,["Open Beta Crest"]=true,
        ["Maplefall Seal"]=true,["Northstar Seal"]=true,["Archivist Seal"]=true,["Wayglass Seal"]=true,["Wolf Seal"]=true,
    }
    -- 1.3.173 replaces the old purchasable level-style frames with earned
    -- milestone insignia. Any legacy selection falls back to the first seal;
    -- old ownership rows may remain harmlessly hidden for compatibility.
    if not validLevelBadges[RPAL_CHAR_DB.customization.levelBadge] then
        RPAL_CHAR_DB.customization.levelBadge="Initiate Seal"
    end
    if RPAL_CHAR_DB.customization.headerDecoration==nil then RPAL_CHAR_DB.customization.headerDecoration="None" end
    if RPAL_CHAR_DB.customization.headerDecoration=="Alliance" or RPAL_CHAR_DB.customization.headerDecoration=="Fel" or RPAL_CHAR_DB.customization.headerDecoration=="Runic" then
        RPAL_CHAR_DB.customization.headerDecoration="None"
    end
    if RPAL_CHAR_DB.customization.background==nil then RPAL_CHAR_DB.customization.background="None" end
    if RPAL_CHAR_DB.customization.profileAura==nil then RPAL_CHAR_DB.customization.profileAura="None" end
    if RPAL_CHAR_DB.customization.uiOrnament==nil then RPAL_CHAR_DB.customization.uiOrnament="Classic" end
    if RPAL_CHAR_DB.customization.effectPack==nil then RPAL_CHAR_DB.customization.effectPack="None" end
    if RPAL_CHAR_DB.customization.toastFrame==nil then RPAL_CHAR_DB.customization.toastFrame="Classic" end
    if RPAL_CHAR_DB.customization.tooltipTreatment==nil then RPAL_CHAR_DB.customization.tooltipTreatment="Classic" end
    if RPAL_CHAR_DB.customization.focusMark==nil then RPAL_CHAR_DB.customization.focusMark="Classic" end
    if RPAL_CHAR_DB.customization.odynEye==nil then RPAL_CHAR_DB.customization.odynEye="Default" end
    RPAL_CHAR_DB.customization.profileFlourish=nil
    if RPAL_CHAR_DB.customization.platformStyle==nil then RPAL_CHAR_DB.customization.platformStyle="Default" end
    if RPAL_CHAR_DB.customization.characterAnimation==nil then RPAL_CHAR_DB.customization.characterAnimation="Stand" end
    -- 1.3.85 turns the old single equipped animation into a character-specific
    -- multi-selection. On first migration every animation the account already
    -- owns is selected, while the old scalar value remains as a compatibility
    -- hint for older RPAL clients.
    if type(RPAL_CHAR_DB.customization.characterAnimations)~="table" then
        RPAL_CHAR_DB.customization.characterAnimations={}
        local ownedAnimations=RPAL_DB.customization.unlockedCosmetics.animation or {}
        for animationName,isOwned in pairs(ownedAnimations) do
            if isOwned and animationName~="Stand" then
                RPAL_CHAR_DB.customization.characterAnimations[animationName]=true
            end
        end
        local legacyAnimation=RPAL_CHAR_DB.customization.characterAnimation
        if legacyAnimation and legacyAnimation~="Stand" and ownedAnimations[legacyAnimation] then
            RPAL_CHAR_DB.customization.characterAnimations[legacyAnimation]=true
        end
    end

    -- 1.3.74 replaces the old ASCII scenery catalog with painted/animated
    -- Profile Atmospheres while preserving ownership and equipped choices.
    do
        local atmosphereMigration={
            ["Stormwind Keep"]="Stormwind Dawn",
            ["Moonlit Towers"]="Moonlit Mist",
            ["Ruined Citadel"]="Graveyard Fog",
            ["Arcane Spire"]="Arcane Constellations",
            ["Forest Gate"]="Dreambloom",
            ["Cathedral Nave"]="Candlelit Parchment",
            ["Harbor Watch"]="Moonlit Mist",
            ["Mountain Hold"]="Frosted Glass",
            ["Elven Ruins"]="Dreambloom",
            ["Graveyard Gate"]="Graveyard Fog",
            ["Roadside Camp"]="Candlelit Parchment",
        }
        local unlocked=RPAL_DB.customization.unlockedCosmetics.background
        for oldName,newName in pairs(atmosphereMigration) do
            if unlocked[oldName] then unlocked[newName]=true end
        end
        local equipped=RPAL_CHAR_DB.customization.background
        if atmosphereMigration[equipped] then
            RPAL_CHAR_DB.customization.background=atmosphereMigration[equipped]
        end
    end

    if RPAL_CHAR_DB.customization.themeName==nil then
        RPAL_CHAR_DB.customization.themeName=(RPAL_DB.settings and RPAL_DB.settings.theme) or "Obsidian Crown"
    end
    if RPAL_CHAR_DB.customization.themeManualOverride==nil then
        RPAL_CHAR_DB.customization.themeManualOverride=(RPAL_DB.settings and RPAL_DB.settings.themeManualOverride~=nil) and (RPAL_DB.settings.themeManualOverride and true or false) or true
    end
    if RPAL_CHAR_DB.customization.themeName=="Harvest Hearth" then RPAL_CHAR_DB.customization.themeName="Obsidian Crown" end
    if RPAL_CHAR_DB.customization.headerDecoration=="Copperleaf" or RPAL_CHAR_DB.customization.headerDecoration=="Amber Canopy" then RPAL_CHAR_DB.customization.headerDecoration="None" end
    if RPAL_CHAR_DB.customization.portraitFrame=="Wayfarer's Wreath" or RPAL_CHAR_DB.customization.portraitFrame=="Gloamwood Crown" then RPAL_CHAR_DB.customization.portraitFrame="Default" end
    if RPAL_CHAR_DB.customization.background=="Harvest Moon" then RPAL_CHAR_DB.customization.background="None" end
    if RPAL_CHAR_DB.customization.profileAura=="Harvest Ember" or RPAL_CHAR_DB.customization.profileAura=="Foxfire Amber" then RPAL_CHAR_DB.customization.profileAura="None" end
    if RPAL_CHAR_DB.customization.uiOrnament=="Oakbound Ledger" then RPAL_CHAR_DB.customization.uiOrnament="Classic" end
    if RPAL_CHAR_DB.customization.effectPack=="Falling Leaves" or RPAL_CHAR_DB.customization.effectPack=="Bonfire Waltz" then RPAL_CHAR_DB.customization.effectPack="None" end

    RPAL_DB.settings = RPAL_DB.settings or {}
    if RPAL_DB.settings.theme == nil then RPAL_DB.settings.theme = "Obsidian Crown" end
    if RPAL_DB.settings.theme=="Harvest Hearth" then RPAL_DB.settings.theme="Obsidian Crown" end
    RPAL_DB.customization.unlockedThemes["Harvest Hearth"]=nil
    local retiredLegacyCosmetics={
        header={"Copperleaf","Amber Canopy"},portrait={"Wayfarer's Wreath","Gloamwood Crown"},
        background={"Harvest Moon"},aura={"Harvest Ember","Foxfire Amber"},
        ornament={"Oakbound Ledger"},effect={"Falling Leaves","Bonfire Waltz"},
    }
    for kind,names in pairs(retiredLegacyCosmetics) do
        local owned=RPAL_DB.customization.unlockedCosmetics[kind]
        if type(owned)=="table" then for _,name in ipairs(names) do owned[name]=nil end end
    end
    if RPAL_DB.settings.autoTRPUpdate == nil then RPAL_DB.settings.autoTRPUpdate = true end
    if RPAL_DB.settings.autoDeclineExchanges == nil then RPAL_DB.settings.autoDeclineExchanges = false end
    if RPAL_DB.settings.sessionWidgetEnabled == nil then RPAL_DB.settings.sessionWidgetEnabled = false end
    if RPAL_DB.settings.uiScale == nil then RPAL_DB.settings.uiScale = 1.0 end
    if RPAL_DB.settings.fontScale == nil then RPAL_DB.settings.fontScale = 1.20 end
    if math.floor(tonumber(RPAL_DB.settings.fontPolishVersion) or 0)<1 then
        if math.abs((tonumber(RPAL_DB.settings.fontScale) or 1.08)-1.08)<0.001 then RPAL_DB.settings.fontScale=1.12 end
        RPAL_DB.settings.fontPolishVersion=1
    end
    -- Hotfix 10: the previous ODYN default (112%) still reads too small on
    -- common WoW UI scales.  Raise only installs that are still sitting on
    -- that previous default; custom font-scale choices are left untouched.
    if math.floor(tonumber(RPAL_DB.settings.fontPolishVersion) or 0)<2 then
        if math.abs((tonumber(RPAL_DB.settings.fontScale) or 1.20)-1.12)<0.001 then RPAL_DB.settings.fontScale=1.20 end
        RPAL_DB.settings.fontPolishVersion=2
    end
    if RPAL_DB.settings.uiLayout == nil then RPAL_DB.settings.uiLayout = "desk" end
    if type(RPAL_DB.settings.uiNavOrder) ~= "table" then RPAL_DB.settings.uiNavOrder = {} end
    if RPAL_DB.settings.uiNavOrderVersion == nil then RPAL_DB.settings.uiNavOrderVersion = 0 end
    if RPAL_DB.settings.dmKitEnabled == nil then RPAL_DB.settings.dmKitEnabled = false end
    if RPAL_DB.settings.experienceMode~="lite" and RPAL_DB.settings.experienceMode~="full" then RPAL_DB.settings.experienceMode="full" end
    if RPAL_DB.settings.themeManualOverride == nil then RPAL_DB.settings.themeManualOverride = true end
    if RPAL_DB.settings.playerFrameWidgetEnabled == nil then RPAL_DB.settings.playerFrameWidgetEnabled = true end
    if (tonumber(RPAL_DB.settings.playerFrameWidgetPositionVersion) or 0) < 2 then
        local oldAngle=tonumber(RPAL_DB.settings.playerFrameWidgetAngle)
        local oldRadius=tonumber(RPAL_DB.settings.playerFrameWidgetRadius)
        if oldAngle==nil or (math.abs(oldAngle-35)<0.01 and (oldRadius==nil or math.abs(oldRadius-62)<0.01)) then
            RPAL_DB.settings.playerFrameWidgetAngle=215
            RPAL_DB.settings.playerFrameWidgetRadius=62
        end
        RPAL_DB.settings.playerFrameWidgetPositionVersion=2
    end
    if RPAL_DB.settings.playerFrameWidgetAngle == nil then RPAL_DB.settings.playerFrameWidgetAngle = 215 end
    if RPAL_DB.settings.playerFrameWidgetRadius == nil then RPAL_DB.settings.playerFrameWidgetRadius = 62 end
    if RPAL_DB.settings.minimapWidgetEnabled == nil then RPAL_DB.settings.minimapWidgetEnabled = true end
    if RPAL_DB.settings.minimapWidgetAngle == nil then RPAL_DB.settings.minimapWidgetAngle = 225 end
    if RPAL_DB.settings.survivalEnabled == nil then RPAL_DB.settings.survivalEnabled = true end
    if RPAL_DB.settings.personaEnabled == nil then RPAL_DB.settings.personaEnabled = true end
    if RPAL_DB.settings.survivalPauseWhileOOC == nil then RPAL_DB.settings.survivalPauseWhileOOC = false end
    if RPAL_DB.settings.foodDecayHours == nil then RPAL_DB.settings.foodDecayHours = 3.5 end
    if RPAL_DB.settings.waterDecayHours == nil then RPAL_DB.settings.waterDecayHours = 1.75 end
    if RPAL_DB.settings.survivalWidgetX == nil then RPAL_DB.settings.survivalWidgetX = 0 end
    if RPAL_DB.settings.survivalWidgetY == nil then RPAL_DB.settings.survivalWidgetY = -120 end
    if type(RPAL_DB.settings.toastLayout) ~= "table" then RPAL_DB.settings.toastLayout = {} end
    if RPAL_DB.settings.toastLayout.x == nil then RPAL_DB.settings.toastLayout.x = 0 end
    if RPAL_DB.settings.toastLayout.y == nil then RPAL_DB.settings.toastLayout.y = 160 end
    if RPAL_DB.settings.toastLayout.scale == nil then RPAL_DB.settings.toastLayout.scale = 1 end

    -- Never take away a theme an existing user was already using.
    if RPAL_DB.settings.themeManualOverride and RPAL_DB.settings.theme then
        RPAL_DB.customization.unlockedThemes[RPAL_DB.settings.theme] = true
    end

    -- Stability.lua owns the forward-compatible schema gate. Core remains
    -- tolerant when loaded alone, while normal ODYN startup refuses to stamp a
    -- partially failed future migration as successful.
    if RPAL.ApplyDataMigrations then
        local schemaOK,schemaResult=RPAL.ApplyDataMigrations(RPAL_DB,RPAL_CHAR_DB)
        if schemaOK==false then error("ODYN data migration failed: "..tostring(schemaResult or "unknown error")) end
    end

    RPAL.db = RPAL_DB
    RPAL.char = RPAL_CHAR_DB
    RPAL.scope = RPAL.scope or "character"

    -- TRP may already be available at PLAYER_LOGIN.  If it is not, the live
    -- status monitor performs the same bind as soon as Total RP 3 exposes the
    -- active profile ID.
    if RPAL.SyncTRPProfileScope then RPAL.SyncTRPProfileScope(true) end

    -- Preserve ordinary store/free themes in the newly bound profile collection.
    -- Progression themes must stay governed by their new source after the Fall
    -- economy migration instead of being silently re-added by an old selection.
    if RPAL_DB.settings.themeManualOverride and RPAL_DB.settings.theme then
        local collection=RPAL.GetCosmeticCollection and RPAL.GetCosmeticCollection()
        local progression=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource("theme",RPAL_DB.settings.theme)
        if collection and not progression then collection.unlockedThemes[RPAL_DB.settings.theme]=true end
    end

    if RPAL.ReconcileLevelRewards then RPAL.ReconcileLevelRewards() end

    -- Convert pre-shop Renown to Crests once per character.
    RPAL_DB.currencyMigrationCharacters = RPAL_DB.currencyMigrationCharacters or {}
    local migrationKey=UnitGUID("player")
    if not migrationKey or migrationKey=="" then
        local n,r=UnitFullName("player")
        migrationKey=tostring(n or "Unknown").."-"..tostring(r or GetRealmName() or "")
    end
    if not RPAL_DB.currencyMigrationCharacters[migrationKey] then
        RPAL_DB.currencyMigrationCharacters[migrationKey]=true
        local oldRenown=math.max(0,math.floor(tonumber(RPAL_CHAR_DB.statistics and RPAL_CHAR_DB.statistics.score) or 0))
        RPAL_DB.currency.crests=RPAL_DB.currency.crests+(oldRenown*3)
    end
end

-- Replace SavedVariables tables in-place for the requested scope, then bind
-- every RPAL accessor to the fresh tables immediately.  UI.lua performs the
-- visual rebuild; keeping the data mutation here also makes Reset Data usable
-- and refreshable without requiring /reload.
function RPAL.ResetDataState(scope)
    if scope~="character" and scope~="account" and scope~="all" then
        return false,"Unknown reset scope."
    end

    local migrationKey=UnitGUID("player")
    if not migrationKey or migrationKey=="" then
        local name,realm=UnitFullName("player")
        migrationKey=tostring(name or "Unknown").."-"..tostring(realm or GetRealmName() or "")
    end

    if scope=="character" then
        RPAL_CHAR_DB={}
    elseif scope=="account" then
        local characterData=RPAL_CHAR_DB
        -- Reset must not re-run the historical Renown-to-Crests migration and
        -- immediately repopulate the account currency it just wiped.
        RPAL_DB={currencyMigrationCharacters={[migrationKey]=true}}
        RPAL_CHAR_DB=characterData
    else
        RPAL_DB={currencyMigrationCharacters={[migrationKey]=true}}
        RPAL_CHAR_DB={}
        RPAL.scope="character"
    end

    RPAL.InitializeDB()
    return true
end

function RPAL.GetSettings()
    RPAL.db.settings = RPAL.db.settings or {}
    if RPAL.db.settings.theme==nil then RPAL.db.settings.theme="Obsidian Crown" end
    if RPAL.db.settings.autoTRPUpdate==nil then RPAL.db.settings.autoTRPUpdate=true end
    if RPAL.db.settings.autoDeclineExchanges==nil then RPAL.db.settings.autoDeclineExchanges=false end
    if RPAL.db.settings.sessionWidgetEnabled==nil then RPAL.db.settings.sessionWidgetEnabled=false end
    if RPAL.db.settings.uiScale==nil then RPAL.db.settings.uiScale=1 end
    if RPAL.db.settings.fontScale==nil then RPAL.db.settings.fontScale=1.20 end
    if RPAL.db.settings.uiLayout==nil then RPAL.db.settings.uiLayout="desk" end
    if type(RPAL.db.settings.uiNavOrder)~="table" then RPAL.db.settings.uiNavOrder={} end
    if RPAL.db.settings.uiNavOrderVersion==nil then RPAL.db.settings.uiNavOrderVersion=0 end
    if type(RPAL.db.settings.uiNavHidden)~="table" then RPAL.db.settings.uiNavHidden={} end
    if type(RPAL.db.settings.playerWidgetMenuVisible)~="table" then RPAL.db.settings.playerWidgetMenuVisible={main=true,injury=true,currencies=true} end
    if RPAL.db.settings.dmKitEnabled==nil then RPAL.db.settings.dmKitEnabled=false end
    if RPAL.db.settings.experienceMode~="lite" and RPAL.db.settings.experienceMode~="full" then RPAL.db.settings.experienceMode="full" end
    if RPAL.db.settings.themeManualOverride==nil then RPAL.db.settings.themeManualOverride=true end
    if RPAL.db.settings.playerFrameWidgetEnabled==nil then RPAL.db.settings.playerFrameWidgetEnabled=true end
    if (tonumber(RPAL.db.settings.playerFrameWidgetPositionVersion) or 0)<2 then
        local oldAngle=tonumber(RPAL.db.settings.playerFrameWidgetAngle)
        local oldRadius=tonumber(RPAL.db.settings.playerFrameWidgetRadius)
        if oldAngle==nil or (math.abs(oldAngle-35)<0.01 and (oldRadius==nil or math.abs(oldRadius-62)<0.01)) then
            RPAL.db.settings.playerFrameWidgetAngle=215
            RPAL.db.settings.playerFrameWidgetRadius=62
        end
        RPAL.db.settings.playerFrameWidgetPositionVersion=2
    end
    if RPAL.db.settings.playerFrameWidgetAngle==nil then RPAL.db.settings.playerFrameWidgetAngle=215 end
    if RPAL.db.settings.playerFrameWidgetRadius==nil then RPAL.db.settings.playerFrameWidgetRadius=62 end
    if RPAL.db.settings.minimapWidgetEnabled==nil then RPAL.db.settings.minimapWidgetEnabled=true end
    if RPAL.db.settings.minimapWidgetAngle==nil then RPAL.db.settings.minimapWidgetAngle=225 end
    -- Hover Log is deliberately lightweight: only preferences persist. Nearby RP history stays runtime-only.
    if RPAL.db.settings.hoverLogEnabled==nil then RPAL.db.settings.hoverLogEnabled=true end
    if RPAL.db.settings.hoverIdentityCardEnabled==nil then RPAL.db.settings.hoverIdentityCardEnabled=true end
    if RPAL.db.settings.hoverLogHotkey==nil then RPAL.db.settings.hoverLogHotkey="E" end
    if RPAL.db.settings.quickActionHotkey==nil then RPAL.db.settings.quickActionHotkey="ALT" end
    if RPAL.db.settings.hoverLogOpacity==nil then RPAL.db.settings.hoverLogOpacity=0.52 end
    -- 1.4.9 gives Hover Log a little more breathing room while dropping the
    -- body font by one point. Preserve custom layouts; only migrate the exact
    -- previous stable defaults.
    if (tonumber(RPAL.db.settings.hoverLogLayoutVersion) or 0)<3 then
        local oldFont=tonumber(RPAL.db.settings.hoverLogFontSize)
        local oldWidth=tonumber(RPAL.db.settings.hoverLogWidth)
        local oldHeight=tonumber(RPAL.db.settings.hoverLogHeight)
        if oldFont==nil or oldFont==16 then RPAL.db.settings.hoverLogFontSize=15 end
        if oldWidth==nil or oldWidth==286 then RPAL.db.settings.hoverLogWidth=340 end
        if oldHeight==nil or oldHeight==144 then RPAL.db.settings.hoverLogHeight=180 end
        RPAL.db.settings.hoverLogLayoutVersion=3
    end
    -- The performance/focus hotfix nudges only the 1.4.9 default layout up by
    -- ten pixels in each direction. Explicit user sizing is left untouched.
    if (tonumber(RPAL.db.settings.hoverLogLayoutVersion) or 0)<4 then
        local oldWidth=tonumber(RPAL.db.settings.hoverLogWidth)
        local oldHeight=tonumber(RPAL.db.settings.hoverLogHeight)
        if oldWidth==nil or oldWidth==340 then RPAL.db.settings.hoverLogWidth=350 end
        if oldHeight==nil or oldHeight==180 then RPAL.db.settings.hoverLogHeight=190 end
        RPAL.db.settings.hoverLogLayoutVersion=4
    end
    -- 1.4.13 makes the Chat Log a proper movable/resizable panel. Migrate only
    -- the exact 1.4.12 defaults; any player-customised size remains untouched.
    if (tonumber(RPAL.db.settings.hoverLogLayoutVersion) or 0)<5 then
        local oldFont=tonumber(RPAL.db.settings.hoverLogFontSize)
        local oldWidth=tonumber(RPAL.db.settings.hoverLogWidth)
        local oldHeight=tonumber(RPAL.db.settings.hoverLogHeight)
        if oldFont==nil or oldFont==15 then RPAL.db.settings.hoverLogFontSize=16 end
        if oldWidth==nil or oldWidth==350 then RPAL.db.settings.hoverLogWidth=400 end
        if oldHeight==nil or oldHeight==190 then RPAL.db.settings.hoverLogHeight=250 end
        RPAL.db.settings.hoverLogLayoutVersion=5
    end
    if RPAL.db.settings.hoverLogFontSize==nil then RPAL.db.settings.hoverLogFontSize=16 end
    if RPAL.db.settings.hoverLogWidth==nil then RPAL.db.settings.hoverLogWidth=400 end
    if RPAL.db.settings.hoverLogHeight==nil then RPAL.db.settings.hoverLogHeight=250 end
    if RPAL.db.settings.hoverLogMentionSound==nil then RPAL.db.settings.hoverLogMentionSound=true end
    if RPAL.db.settings.hoverLogTargetSound==nil then RPAL.db.settings.hoverLogTargetSound=true end
    -- HOTFIX14: mention and directed-interaction alerts use independent sound
    -- presets. Migrate legacy booleans once; neither default uses TELL_MESSAGE.
    if (tonumber(RPAL.db.settings.hoverLogSoundChoiceVersion) or 0)<1 then
        if RPAL.db.settings.hoverLogMentionSoundChoice==nil then
            RPAL.db.settings.hoverLogMentionSoundChoice=RPAL.db.settings.hoverLogMentionSound==false and "none" or "odyn_chime"
        end
        if RPAL.db.settings.hoverLogTargetSoundChoice==nil then
            RPAL.db.settings.hoverLogTargetSoundChoice=RPAL.db.settings.hoverLogTargetSound==false and "none" or "emote_tap"
        end
        RPAL.db.settings.hoverLogSoundChoiceVersion=1
    end
    if RPAL.db.settings.hoverLogMentionSoundChoice==nil then RPAL.db.settings.hoverLogMentionSoundChoice="odyn_chime" end
    if RPAL.db.settings.hoverLogTargetSoundChoice==nil then RPAL.db.settings.hoverLogTargetSoundChoice="emote_tap" end
    -- HOTFIX18 removes the Ready Check preset entirely. Preserve a sensible
    -- alert for players who had already selected it in HOTFIX14-17.
    if tostring(RPAL.db.settings.hoverLogMentionSoundChoice)=="ready_check" then RPAL.db.settings.hoverLogMentionSoundChoice="odyn_chime" end
    if tostring(RPAL.db.settings.hoverLogTargetSoundChoice)=="ready_check" then RPAL.db.settings.hoverLogTargetSoundChoice="emote_tap" end
    -- HOTFIX22: repair installs where both custom channels inherited the
    -- mention preset. Only the exact old-default pair is migrated.
    if (tonumber(RPAL.db.settings.hoverLogSoundChoiceVersion) or 0)<3 then
        if tostring(RPAL.db.settings.hoverLogMentionSoundChoice)=="odyn_chime"
           and tostring(RPAL.db.settings.hoverLogTargetSoundChoice)=="odyn_chime" then
            RPAL.db.settings.hoverLogTargetSoundChoice="emote_tap"
        end
        RPAL.db.settings.hoverLogSoundChoiceVersion=3
    end
    if RPAL.db.settings.chatQoLEnabled==nil then RPAL.db.settings.chatQoLEnabled=true end
    if RPAL.db.settings.chatSplitterEnabled==nil then RPAL.db.settings.chatSplitterEnabled=true end
    if type(RPAL.db.settings.keybinds)~="table" then RPAL.db.settings.keybinds={} end
    -- 1.4.11 turns the old Alt-to-Hover-Log gesture into a Quick Action Menu.
    -- Migrate only the exact former default so custom Hover Log bindings remain
    -- respected. Quick-menu actions are contextual and may intentionally reuse
    -- normal WoW keys because ODYN overrides them only while that menu is open.
    if (tonumber(RPAL.db.settings.quickActionBindingDefaultVersion) or 0)<1 then
        if tostring(RPAL.db.settings.hoverLogHotkey or ""):upper()=="ALT" then RPAL.db.settings.hoverLogHotkey="E" end
        if RPAL.db.settings.quickActionHotkey==nil or RPAL.db.settings.quickActionHotkey=="" then RPAL.db.settings.quickActionHotkey="ALT" end
        if RPAL.db.settings.keybinds.quickinspect==nil then RPAL.db.settings.keybinds.quickinspect="Q" end
        RPAL.db.settings.quickActionBindingDefaultVersion=1
    end
    -- 1.4.12 expands Quick Actions into four fixed contextual actions. Apply
    -- defaults once so existing users receive Whisper/Open TRP without
    -- overwriting any custom binds they already set.
    if (tonumber(RPAL.db.settings.quickActionBindingDefaultVersion) or 0)<2 then
        if RPAL.db.settings.keybinds.quickinspect==nil then RPAL.db.settings.keybinds.quickinspect="Q" end
        if RPAL.db.settings.hoverLogHotkey==nil or RPAL.db.settings.hoverLogHotkey=="" then RPAL.db.settings.hoverLogHotkey="E" end
        if RPAL.db.settings.keybinds.quickwhisper==nil then RPAL.db.settings.keybinds.quickwhisper="R" end
        if RPAL.db.settings.keybinds.quicktrp==nil then RPAL.db.settings.keybinds.quicktrp="F" end
        RPAL.db.settings.quickActionBindingDefaultVersion=2
    end
    -- Focus Messages is a contextual shortcut: it shadows the underlying WoW
    -- action only while the Hover Log panel is open. Give both new and existing
    -- installs the requested one-time default without re-adding it after the
    -- player deliberately clears the binding later.
    if (tonumber(RPAL.db.settings.focusMessagesBindingDefaultVersion) or 0)<1 then
        if RPAL.db.settings.keybinds.lendear==nil then RPAL.db.settings.keybinds.lendear="E" end
        RPAL.db.settings.focusMessagesBindingDefaultVersion=1
    end
    -- 1.6.7 adds a chat-local Self/Companion speaker switch. TAB is consumed
    -- only while the chat edit box is active, so normal gameplay TAB behavior
    -- remains untouched. Apply the default once and respect later unbinding.
    if (tonumber(RPAL.db.settings.chatSpeakerBindingDefaultVersion) or 0)<1 then
        if RPAL.db.settings.keybinds.chatspeaker==nil then RPAL.db.settings.keybinds.chatspeaker="TAB" end
        RPAL.db.settings.chatSpeakerBindingDefaultVersion=1
    end
    if RPAL.db.settings.survivalEnabled==nil then RPAL.db.settings.survivalEnabled=true end
    if RPAL.db.settings.personaEnabled==nil then RPAL.db.settings.personaEnabled=true end
    if RPAL.db.settings.survivalPauseWhileOOC==nil then RPAL.db.settings.survivalPauseWhileOOC=false end
    if RPAL.db.settings.foodDecayHours==nil then RPAL.db.settings.foodDecayHours=3.5 end
    if RPAL.db.settings.waterDecayHours==nil then RPAL.db.settings.waterDecayHours=1.75 end
    if RPAL.db.settings.survivalWidgetX==nil then RPAL.db.settings.survivalWidgetX=0 end
    if RPAL.db.settings.survivalWidgetY==nil then RPAL.db.settings.survivalWidgetY=-120 end
    if type(RPAL.db.settings.toastLayout)~="table" then RPAL.db.settings.toastLayout={} end
    if RPAL.db.settings.toastLayout.x==nil then RPAL.db.settings.toastLayout.x=0 end
    if RPAL.db.settings.toastLayout.y==nil then RPAL.db.settings.toastLayout.y=160 end
    if RPAL.db.settings.toastLayout.scale==nil then RPAL.db.settings.toastLayout.scale=1 end
    return RPAL.db.settings
end

function RPAL.GetFontScale()
    local value=1.20
    if RPAL.db and RPAL.db.settings then
        value=tonumber(RPAL.db.settings.fontScale) or value
    end
    return math.max(0.90,math.min(1.35,value))
end

function RPAL.GetScaledFontSize(baseSize)
    baseSize=tonumber(baseSize) or 10
    return math.max(6,math.min(72,baseSize*RPAL.GetFontScale()))
end

function RPAL.RegisterScalableFontObject(object,fontPath,baseSize,flags)
    if not object or type(object.SetFont)~="function" then return object end
    RPAL._scalableFontObjects=RPAL._scalableFontObjects or setmetatable({}, {__mode="k"})
    RPAL._scalableFontObjects[object]={
        font=fontPath or "Fonts\\FRIZQT__.TTF",
        size=tonumber(baseSize) or 10,
        flags=flags or "",
    }
    pcall(object.SetFont,object,RPAL._scalableFontObjects[object].font,RPAL.GetScaledFontSize(baseSize),flags or "")
    return object
end

function RPAL.ApplyFontScale()
    local registry=RPAL._scalableFontObjects
    if type(registry)~="table" then return end
    for object,meta in pairs(registry) do
        if object and type(object.SetFont)=="function" and type(meta)=="table" then
            pcall(object.SetFont,object,meta.font or "Fonts\\FRIZQT__.TTF",RPAL.GetScaledFontSize(meta.size or 10),meta.flags or "")
            if object._rpalFitWidth and RPAL.FitFontStringToWidth then
                RPAL.FitFontStringToWidth(
                    object,
                    object._rpalFitWidth,
                    object._rpalFitPreferredBase or meta.size or 10,
                    object._rpalFitMinimumBase or 6
                )
            end
        end
    end
end


local survivalSessionElapsed=0

local function ClampSurvival(value)
    return math.max(0,math.min(100,tonumber(value) or 0))
end

local function EnsureSurvivalState()
    RPAL.char.survival=RPAL.char.survival or {}
    local state=RPAL.char.survival
    state.food=ClampSurvival(state.food==nil and 100 or state.food)
    state.water=ClampSurvival(state.water==nil and 100 or state.water)
    -- Legacy timestamps are deliberately ignored from 1.3.45 onward.
    state.lastUpdate=nil
    return state
end

function RPAL.IsSurvivalPausedForOOC()
    local settings=RPAL.GetSettings()
    if not settings.survivalPauseWhileOOC then return false end
    if type(RPAL.GetTRPCharacterStatus)~="function" then return false end
    local ok,status=pcall(RPAL.GetTRPCharacterStatus)
    return ok and status=="OOC" or false
end

function RPAL.UpdateSurvivalState(elapsed)
    local state=EnsureSurvivalState()
    local settings=RPAL.GetSettings()

    if not settings.survivalEnabled then
        return state,false
    end
    if RPAL.IsSurvivalPausedForOOC() then
        return state,false
    end

    elapsed=math.max(0,tonumber(elapsed) or 0)
    if elapsed<=0 then return state,false end

    local wasHungry=state.food<=30
    local wasDehydrated=state.water<=30

    local foodHours=math.max(0.25,tonumber(settings.foodDecayHours) or 3.5)
    local waterHours=math.max(0.25,tonumber(settings.waterDecayHours) or 1.75)

    state.food=ClampSurvival(state.food-(elapsed/(foodHours*3600))*100)
    state.water=ClampSurvival(state.water-(elapsed/(waterHours*3600))*100)

    local flagsChanged=(wasHungry~=(state.food<=30)) or (wasDehydrated~=(state.water<=30))
    return state,flagsChanged
end

function RPAL.GetSurvivalState()
    return EnsureSurvivalState()
end

function RPAL.GetSurvivalFlags()
    local settings=RPAL.GetSettings()
    if not settings.survivalEnabled then return false,false end
    local state=EnsureSurvivalState()
    return state.food<=30,state.water<=30
end

function RPAL.RefillSurvival(kind,amount)
    local settings=RPAL.GetSettings()
    if not settings.survivalEnabled then return false end

    local state=EnsureSurvivalState()
    amount=math.max(0,tonumber(amount) or 0)

    local wasHungry=state.food<=30
    local wasDehydrated=state.water<=30

    if kind=="food" then
        state.food=ClampSurvival(state.food+amount)
    elseif kind=="water" then
        state.water=ClampSurvival(state.water+amount)
    else
        return false
    end

    local flagsChanged=(wasHungry~=(state.food<=30)) or (wasDehydrated~=(state.water<=30))
    return true,flagsChanged,state
end

function RPAL.SetSurvivalEnabled(enabled)
    local settings=RPAL.GetSettings()
    settings.survivalEnabled=enabled and true or false
    EnsureSurvivalState()
    return settings.survivalEnabled
end

function RPAL.SetSurvivalPauseWhileOOC(enabled)
    local settings=RPAL.GetSettings()
    settings.survivalPauseWhileOOC=enabled and true or false
    return settings.survivalPauseWhileOOC
end

function RPAL.SetSurvivalDecayHours(kind,hours)
    hours=tonumber(hours)
    if not hours then return false end

    local settings=RPAL.GetSettings()
    if kind=="food" then
        settings.foodDecayHours=math.max(1,math.min(8,hours))
    elseif kind=="water" then
        settings.waterDecayHours=math.max(0.5,math.min(4,hours))
    else
        return false
    end
    return true
end

-- Currency was renamed from Crests to Stones in 1.3.78. The SavedVariables
-- key and legacy API names remain compatibility aliases so existing balances
-- are preserved without migration loss.
RPAL.STONES_PER_RENOWN=3
RPAL.CRESTS_PER_RENOWN=RPAL.STONES_PER_RENOWN

function RPAL.GetCrests()
    RPAL.db.currency=RPAL.db.currency or {crests=0}
    RPAL.db.currency.crests=math.max(0,math.floor(tonumber(RPAL.db.currency.crests) or 0))
    return RPAL.db.currency.crests
end

function RPAL.AddCrests(amount)
    amount=math.floor(tonumber(amount) or 0)
    if amount<=0 then return 0 end
    RPAL.db.currency=RPAL.db.currency or {crests=0}
    RPAL.db.currency.crests=RPAL.GetCrests()+amount
    if RPAL.OnCrestsChanged then RPAL.OnCrestsChanged(RPAL.db.currency.crests,amount) end
    return amount
end

function RPAL.SpendCrests(amount)
    amount=math.floor(tonumber(amount) or 0)
    if amount<=0 then return true end
    local current=RPAL.GetCrests()
    if current<amount then return false end
    RPAL.db.currency.crests=current-amount
    if RPAL.OnCrestsChanged then RPAL.OnCrestsChanged(RPAL.db.currency.crests,-amount) end
    return true
end

-- Public Stone API. Legacy Crest functions intentionally remain callable.
RPAL.GetStones=RPAL.GetCrests
RPAL.AddStones=RPAL.AddCrests
RPAL.SpendStones=RPAL.SpendCrests

-- Public Token API. SavedVariables and old Stone/Crest aliases remain intact so
-- existing installations migrate without losing currency.
RPAL.TOKENS_PER_RENOWN=RPAL.STONES_PER_RENOWN
RPAL.GetTokens=RPAL.GetStones
RPAL.AddTokens=RPAL.AddStones
RPAL.SpendTokens=RPAL.SpendStones

-- Fall 2026 customization economy migration -----------------------------------
-- The previous stable shop displayed base prices through a 1.12 multiplier.
-- Items moved from that shop into Achievements, the Renown Atlas, Fall Season
-- rewards, or the Part II preview are refunded exactly once at that old visible
-- price. Ownership is then cleared so the new progression source is authoritative.
RPAL.FALL_2026_STORE_REFUND_VERSION=1
RPAL.FALL_2026_MOVED_STORE_BASE_COSTS={
    theme={
        ["Midnight"]=90,["Arcane"]=120,["Fel"]=120,["Blood"]=150,["Kaldorei"]=150,
        ["Stormwind"]=150,["Monochrome"]=90,["Frost"]=120,["Emerald"]=120,["Rosewood"]=120,
        ["Silvermoon"]=160,["Sunset"]=130,["Bronze"]=100,["Dream"]=140,["Drust"]=120,["Nightborne"]=180,
    },
    background={
        ["Candlelit Parchment"]=95,["Moonlit Mist"]=120,["Zodiac Veil"]=205,
        ["Frosted Glass"]=145,["Stormwind Dawn"]=130,["Graveyard Fog"]=140,
    },
    portrait={
        ["Gilded Compass"]=175,["Arcane"]=120,["Kaldorei"]=140,["Bloodthorn"]=150,["Frostbound"]=150,
    },
    header={["Arcane"]=110,["Blood"]=145},
    aura={
        ["Frost Veil"]=155,["Blood Oath"]=160,["Arcane Orbit"]=170,
        ["Emerald Reverie"]=275,["Astral Crown"]=325,
    },
    ornament={["Runic Corners"]=135,["Kaldorei Vines"]=160},
    platform={["Runic Halo"]=90,["Arcane Convergence"]=130,["Moonwell Bloom"]=145},
    animation={
        ["Talk"]=70,["Eat"]=80,["Work"]=80,["Exclaim"]=85,["Question"]=85,["Bow"]=90,["Cheer"]=100,
        ["Dance"]=135,["Laugh"]=95,["Sit"]=85,["Kneel"]=100,["Kiss"]=110,["Applaud"]=105,["Point"]=95,
    },
    effect={["Ashen Rain"]=185,["Moon Dust"]=260,["Gilded Storm"]=340,["Rainfall"]=165,["Snowfall"]=175},
}

local function Fall2026OldDisplayedPrice(baseCost)
    baseCost=math.max(0,tonumber(baseCost) or 0)
    if baseCost<=0 then return 0 end
    return math.max(5,math.floor(((baseCost*1.12)/5)+0.5)*5)
end

local function Fall2026SanitizeMovedSelections(refunded)
    if not RPAL.char or not RPAL.char.customization then return end
    local c=RPAL.char.customization
    refunded=type(refunded)=="table" and refunded or {}
    local refundedTheme=type(refunded.theme)=="table" and refunded.theme or nil
    if c.themeManualOverride and c.themeName and ((refundedTheme and refundedTheme[c.themeName]) or not (RPAL.IsThemeUnlocked and RPAL.IsThemeUnlocked(c.themeName))) then
        c.themeName="Obsidian Crown"
        c.themeManualOverride=true
    end
    local slots={
        portraitFrame={kind="portrait",fallback="Default"},
        levelBadge={kind="level",fallback="Initiate Seal"},
        headerDecoration={kind="header",fallback="None"},
        background={kind="background",fallback="None"},
        profileAura={kind="aura",fallback="None"},
        uiOrnament={kind="ornament",fallback="Classic"},
        effectPack={kind="effect",fallback="None"},
        toastFrame={kind="toast",fallback="Classic"},
        tooltipTreatment={kind="tooltip",fallback="Classic"},
        focusMark={kind="focus",fallback="Classic"},
        odynEye={kind="eye",fallback="Default"},
        platformStyle={kind="platform",fallback="Default"},
    }
    for field,def in pairs(slots) do
        local selected=c[field]
        local refundedKind=type(refunded[def.kind])=="table" and refunded[def.kind] or nil
        if selected and ((refundedKind and refundedKind[selected]) or not (RPAL.IsCosmeticUnlocked and RPAL.IsCosmeticUnlocked(def.kind,selected))) then c[field]=def.fallback end
    end
    c.characterAnimations=type(c.characterAnimations)=="table" and c.characterAnimations or {}
    local refundedAnimations=type(refunded.animation)=="table" and refunded.animation or nil
    for name,enabled in pairs(c.characterAnimations) do
        if enabled and ((refundedAnimations and refundedAnimations[name]) or not (RPAL.IsCosmeticUnlocked and RPAL.IsCosmeticUnlocked("animation",name))) then c.characterAnimations[name]=nil end
    end
    if c.characterAnimation and c.characterAnimation~="Stand" and ((refundedAnimations and refundedAnimations[c.characterAnimation]) or not (RPAL.IsCosmeticUnlocked and RPAL.IsCosmeticUnlocked("animation",c.characterAnimation))) then
        c.characterAnimation="Stand"
    end
end

function RPAL.ApplyFall2026StoreRefundMigration()
    if not RPAL.db or not RPAL.char then return 0,0 end
    local collection=RPAL.GetCosmeticCollection and RPAL.GetCosmeticCollection()
    if not collection then return 0,0 end
    local version=math.floor(tonumber(collection.fall2026StoreRefundVersion) or 0)
    if version>=RPAL.FALL_2026_STORE_REFUND_VERSION then return 0,0 end

    local refund,count=0,0
    local refunded={}
    collection.unlockedThemes=type(collection.unlockedThemes)=="table" and collection.unlockedThemes or {}
    collection.unlockedCosmetics=type(collection.unlockedCosmetics)=="table" and collection.unlockedCosmetics or {}
    for kind,costs in pairs(RPAL.FALL_2026_MOVED_STORE_BASE_COSTS or {}) do
        local owned
        if kind=="theme" then
            owned=collection.unlockedThemes
        else
            collection.unlockedCosmetics[kind]=type(collection.unlockedCosmetics[kind])=="table" and collection.unlockedCosmetics[kind] or {}
            owned=collection.unlockedCosmetics[kind]
        end
        for name,baseCost in pairs(costs) do
            if owned[name] then
                owned[name]=nil
                refunded[kind]=refunded[kind] or {}
                refunded[kind][name]=true
                refund=refund+Fall2026OldDisplayedPrice(baseCost)
                count=count+1
            end
        end
    end

    -- RPAL_DB.customization is only a legacy migration source now. Remove the
    -- same moved-store flags there without paying a second refund, otherwise a
    -- future character reset could copy stale ownership and reimburse it again.
    if type(RPAL_DB)=="table" and type(RPAL_DB.customization)=="table" then
        local legacy=RPAL_DB.customization
        legacy.unlockedThemes=type(legacy.unlockedThemes)=="table" and legacy.unlockedThemes or {}
        legacy.unlockedCosmetics=type(legacy.unlockedCosmetics)=="table" and legacy.unlockedCosmetics or {}
        for kind,costs in pairs(RPAL.FALL_2026_MOVED_STORE_BASE_COSTS or {}) do
            local owned=(kind=="theme") and legacy.unlockedThemes or legacy.unlockedCosmetics[kind]
            if type(owned)=="table" then for name in pairs(costs) do owned[name]=nil end end
        end
        legacy.fall2026StoreRefundVersion=RPAL.FALL_2026_STORE_REFUND_VERSION
    end

    collection.fall2026StoreRefundVersion=RPAL.FALL_2026_STORE_REFUND_VERSION
    collection.fall2026StoreRefundAmount=refund
    collection.fall2026StoreRefundCount=count
    collection.fall2026StoreRefundAt=time and time() or 0
    if refund>0 and RPAL.AddCrests then RPAL.AddCrests(refund) end

    -- A refunded cosmetic changed acquisition source. Even when this character
    -- already qualifies for that new source, never silently leave the old shop
    -- selection equipped: return the affected slot to its default first.
    Fall2026SanitizeMovedSelections(refunded)

    -- Re-apply legitimate progression ownership after the loadout has been reset.
    -- The item can be equipped again deliberately from its new reward source.
    if RPAL.ReconcileLevelRewards then pcall(RPAL.ReconcileLevelRewards) end
    if RPAL.ApplyTalentRewards then pcall(RPAL.ApplyTalentRewards) end
    if RPAL.CheckAutomaticAchievements then pcall(RPAL.CheckAutomaticAchievements,false,false) end
    Fall2026SanitizeMovedSelections()

    if refund>0 and DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
        DEFAULT_CHAT_FRAME:AddMessage("|cffd9a644ODYN:|r Refunded "..tostring(refund).." Tokens for "..tostring(count).." customization"..(count==1 and "" or "s").." moved from the shop into progression.")
    end
    return refund,count
end

-- ODYN Eye progression migration ---------------------------------------------
-- HOTFIX9 removes Focused Gaze and moves every non-default Eye out of the
-- Token shop.  Existing purchases are refunded once at the HOTFIX8 visible
-- price (the legacy rounded price doubled), then legitimate progression grants
-- are reapplied from Achievements, Atlas nodes, levels, and Season Part II.
RPAL.ODYN_EYE_PROGRESSION_MIGRATION_VERSION=1
RPAL.ODYN_EYE_OLD_STORE_BASE_COSTS={
    ["Focused Gaze"]=85,
    ["Runebound Lens"]=95,
    ["Northstar Eye"]=105,
    ["Moonwell Eye"]=110,
    Emberwatch=110,
    ["Voidglass Eye"]=120,
    ["Maplefall Eye"]=105,
    ["Silver Thread"]=115,
    ["Solar Watch"]=125,
}

local function SanitizeODYNEyeSelection()
    if not RPAL.char or not RPAL.char.customization then return end
    local selected=RPAL.char.customization.odynEye
    if not selected then return end
    local removed=selected=="Focused Gaze" or selected=="Northstar Eye"
    if removed or not (RPAL.IsCosmeticUnlocked and RPAL.IsCosmeticUnlocked("eye",selected)) then
        RPAL.char.customization.odynEye="Default"
    end
end

function RPAL.ApplyODYNEyeProgressionMigration()
    if not RPAL.db or not RPAL.char then return 0,0 end
    local collection=RPAL.GetCosmeticCollection and RPAL.GetCosmeticCollection()
    if not collection then return 0,0 end
    local version=math.floor(tonumber(collection.odynEyeProgressionMigrationVersion) or 0)
    if version>=RPAL.ODYN_EYE_PROGRESSION_MIGRATION_VERSION then return 0,0 end

    collection.unlockedCosmetics=type(collection.unlockedCosmetics)=="table" and collection.unlockedCosmetics or {}
    collection.unlockedCosmetics.eye=type(collection.unlockedCosmetics.eye)=="table" and collection.unlockedCosmetics.eye or {}
    local owned=collection.unlockedCosmetics.eye
    local refund,count=0,0
    for name,baseCost in pairs(RPAL.ODYN_EYE_OLD_STORE_BASE_COSTS or {}) do
        if owned[name] then
            owned[name]=nil
            refund=refund+(Fall2026OldDisplayedPrice(baseCost)*2)
            count=count+1
        end
    end

    -- Clear the account-era migration source without refunding it a second
    -- time. This prevents a later profile bootstrap from resurrecting removed
    -- store ownership, including the retired Focused Gaze/Northstar keys.
    if type(RPAL_DB)=="table" and type(RPAL_DB.customization)=="table" then
        local legacy=RPAL_DB.customization
        legacy.unlockedCosmetics=type(legacy.unlockedCosmetics)=="table" and legacy.unlockedCosmetics or {}
        local legacyEyes=legacy.unlockedCosmetics.eye
        if type(legacyEyes)=="table" then
            for name in pairs(RPAL.ODYN_EYE_OLD_STORE_BASE_COSTS or {}) do legacyEyes[name]=nil end
        end
        if legacy.odynEye=="Focused Gaze" or legacy.odynEye=="Northstar Eye" then legacy.odynEye="Default" end
        legacy.odynEyeProgressionMigrationVersion=RPAL.ODYN_EYE_PROGRESSION_MIGRATION_VERSION
    end

    collection.odynEyeProgressionMigrationVersion=RPAL.ODYN_EYE_PROGRESSION_MIGRATION_VERSION
    collection.odynEyeProgressionMigrationRefund=refund
    collection.odynEyeProgressionMigrationCount=count
    collection.odynEyeProgressionMigrationAt=time and time() or 0
    if refund>0 and RPAL.AddCrests then RPAL.AddCrests(refund) end

    -- Always unequip an Eye that changed acquisition source first. Progression
    -- may immediately grant it again, but the player deliberately re-equips it.
    if RPAL.char.customization then RPAL.char.customization.odynEye="Default" end
    if RPAL.ReconcileLevelRewards then pcall(RPAL.ReconcileLevelRewards) end
    if RPAL.ApplyTalentRewards then pcall(RPAL.ApplyTalentRewards) end
    if RPAL.CheckAutomaticAchievements then pcall(RPAL.CheckAutomaticAchievements,false,false) end
    SanitizeODYNEyeSelection()

    if refund>0 and DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
        DEFAULT_CHAT_FRAME:AddMessage("|cffd9a644ODYN:|r Refunded "..tostring(refund).." Tokens for "..tostring(count).." ODYN Eye"..(count==1 and "" or "s").." moved from the shop into progression.")
    end
    return refund,count
end

-- Lite Kit Hearthstone currency ------------------------------------------------
-- Lite intentionally hides the normal Token/customization economy.  HS Tokens
-- are therefore a separate, account-wide reserve that can only be read/spent
-- while Lite Kit is active and only by Unofficial Hearthstone systems.
local function EnsureHSTokenWallet()
    RPAL.db.currency=RPAL.db.currency or {crests=0}
    RPAL.db.currency.hsTokens=math.max(0,math.floor(tonumber(RPAL.db.currency.hsTokens) or 0))
    return RPAL.db.currency
end

function RPAL.GetHSTokens()
    local wallet=EnsureHSTokenWallet()
    if not (RPAL.IsLiteKit and RPAL.IsLiteKit()) then return 0 end
    return wallet.hsTokens
end

function RPAL.PeekHSTokens()
    return EnsureHSTokenWallet().hsTokens
end

function RPAL.AddHSTokens(amount,source)
    if not (RPAL.IsLiteKit and RPAL.IsLiteKit()) then return 0 end
    amount=math.max(0,math.floor(tonumber(amount) or 0))
    if amount<=0 then return 0 end
    local wallet=EnsureHSTokenWallet();wallet.hsTokens=wallet.hsTokens+amount
    if RPAL.OnHSTokensChanged then pcall(RPAL.OnHSTokensChanged,wallet.hsTokens,amount,source) end
    return amount
end

function RPAL.SpendHSTokens(amount)
    if not (RPAL.IsLiteKit and RPAL.IsLiteKit()) then return false end
    amount=math.max(0,math.floor(tonumber(amount) or 0));if amount<=0 then return true end
    local wallet=EnsureHSTokenWallet();if wallet.hsTokens<amount then return false end
    wallet.hsTokens=wallet.hsTokens-amount
    if RPAL.OnHSTokensChanged then pcall(RPAL.OnHSTokensChanged,wallet.hsTokens,-amount,"spend") end
    return true
end

RPAL.HS_TOKEN_RP_COOLDOWN=12
function RPAL.TryAwardLiteHSTokenFromRP(message,eventName)
    if not (RPAL.IsLiteKit and RPAL.IsLiteKit()) then return false,"not_lite" end
    if RPAL.IsTRPInCharacter and not RPAL.IsTRPInCharacter() then return false,"not_ic" end
    local compact=tostring(message or ""):gsub("%s+","")
    if #compact<8 then return false,"too_short" end
    local now=GetTime and GetTime() or time()
    local last=tonumber(RPAL._lastLiteHSTokenRPAt) or 0
    if now-last<(RPAL.HS_TOKEN_RP_COOLDOWN or 12) then return false,"cooldown" end
    RPAL._lastLiteHSTokenRPAt=now
    return RPAL.AddHSTokens(1,"roleplay_message")>0,"granted"
end

function RPAL.IsThemeUnlocked(name)
    if name=="Ancient Book" or name=="Class Specific" then return true end
    local progression=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource("theme",name)
    -- Future-drop previews stay visible but cannot be equipped or inherited from
    -- stale preview/store ownership before their release window opens.
    if progression and progression.source=="future" then return false end
    -- Seasonal themes are entitlements, just like every other seasonal cosmetic.
    -- Read the claim ledger directly so a store-economy migration cannot make an
    -- already-earned theme appear locked merely because its old ownership bit was
    -- intentionally cleared/refunded.
    if progression and progression.source=="season" then
        return RPAL.IsSeasonRewardClaimed and RPAL.IsSeasonRewardClaimed("theme",name) or false
    end
    local collection=RPAL.GetCosmeticCollection and RPAL.GetCosmeticCollection()
    return collection and collection.unlockedThemes[name] and true or false
end

function RPAL.UnlockTheme(name,source)
    local progression=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource("theme",name)
    if progression and progression.source=="future" then return false end
    if progression and source~="progression" then return false end
    local collection=RPAL.GetCosmeticCollection and RPAL.GetCosmeticCollection()
    if not collection then return false end
    collection.unlockedThemes[name]=true
    return true
end

function RPAL.GetSelectedCharacterAnimations()
    RPAL.char.customization=RPAL.char.customization or {}
    RPAL.char.customization.characterAnimations=RPAL.char.customization.characterAnimations or {}
    local selected={}
    for name,enabled in pairs(RPAL.char.customization.characterAnimations) do
        if enabled and name~="Stand" and RPAL.IsCosmeticUnlocked("animation",name) then
            selected[name]=true
        end
    end
    return selected
end

function RPAL.SetCharacterAnimationSelected(name,enabled)
    name=tostring(name or "")
    if name=="" or name=="Stand" or not RPAL.IsCosmeticUnlocked("animation",name) then return false end
    RPAL.char.customization=RPAL.char.customization or {}
    RPAL.char.customization.characterAnimations=RPAL.char.customization.characterAnimations or {}
    if enabled then
        RPAL.char.customization.characterAnimations[name]=true
        RPAL.char.customization.characterAnimation=name
    else
        RPAL.char.customization.characterAnimations[name]=nil
        if RPAL.char.customization.characterAnimation==name then
            RPAL.char.customization.characterAnimation="Stand"
            for selectedName,isSelected in pairs(RPAL.char.customization.characterAnimations) do
                if isSelected then
                    RPAL.char.customization.characterAnimation=selectedName
                    break
                end
            end
        end
    end
    if RPAL.OnCosmeticEquipped then RPAL.OnCosmeticEquipped("animation",name) end
    return true
end

function RPAL.GetCosmeticSelection()
    RPAL.char.customization=RPAL.char.customization or {}
    local selectedAnimations=RPAL.GetSelectedCharacterAnimations()
    local legacyAnimation=RPAL.char.customization.characterAnimation or "Stand"
    if legacyAnimation~="Stand" and not selectedAnimations[legacyAnimation] then
        legacyAnimation="Stand"
    end
    if legacyAnimation=="Stand" then
        for animationName in pairs(selectedAnimations) do
            legacyAnimation=animationName
            break
        end
    end
    return {
        portraitFrame=RPAL.char.customization.portraitFrame or "Default",
        levelBadge=RPAL.char.customization.levelBadge or "Initiate Seal",
        headerDecoration=RPAL.char.customization.headerDecoration or "None",
        background=RPAL.char.customization.background or "None",
        profileAura=RPAL.char.customization.profileAura or "None",
        uiOrnament=RPAL.char.customization.uiOrnament or "Classic",
        effectPack=RPAL.char.customization.effectPack or "None",
        toastFrame=RPAL.char.customization.toastFrame or "Classic",
        tooltipTreatment=RPAL.char.customization.tooltipTreatment or "Classic",
        focusMark=RPAL.char.customization.focusMark or "Classic",
        odynEye=RPAL.char.customization.odynEye or "Default",
        platformStyle=RPAL.char.customization.platformStyle or "Default",
        characterAnimation=legacyAnimation,
        characterAnimations=selectedAnimations,
    }
end

function RPAL.GetCharacterThemeSelection()
    RPAL.char.customization=RPAL.char.customization or {}
    if RPAL.char.customization.themeName==nil then RPAL.char.customization.themeName="Obsidian Crown" end
    if RPAL.char.customization.themeManualOverride==nil then RPAL.char.customization.themeManualOverride=true end
    return RPAL.char.customization
end

function RPAL.IsCosmeticUnlocked(kind,name)
    if name=="Default" or name=="None" then return true end
    if kind=="level" and name=="Captain's Radiance" then
        return RPAL.IsCurrentCharacterMain and RPAL.IsCurrentCharacterMain() or false
    end

    -- Season cosmetics are account entitlements, not Token-store ownership.
    -- Never fall back to profile-scoped unlockedCosmetics for them: older
    -- builds briefly exposed some Season rewards at 0 Tokens, and that stale
    -- SavedVariables ownership must not bypass the Season Level requirement.
    local progression=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource(kind,name)
    if progression and progression.source=="future" then return false end
    if progression and progression.source=="season" then
        return RPAL.IsSeasonRewardClaimed and RPAL.IsSeasonRewardClaimed(kind,name) or false
    end

    local collection=RPAL.GetCosmeticCollection and RPAL.GetCosmeticCollection()
    if not collection then return false end
    collection.unlockedCosmetics[kind]=collection.unlockedCosmetics[kind] or {}
    return collection.unlockedCosmetics[kind][name] and true or false
end

function RPAL.UnlockCosmetic(kind,name,source)
    local progression=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource(kind,name)
    if progression then
        -- Progression cosmetics may only be granted by their canonical source.
        if progression.source=="future" then
            return false
        elseif progression.source=="season" then
            if source~="progression" then return false end
        elseif source~="progression" then
            return false
        end
    end
    if kind=="level" and name=="Captain's Radiance" and not (RPAL.IsCurrentCharacterMain and RPAL.IsCurrentCharacterMain()) then return false end
    local collection=RPAL.GetCosmeticCollection and RPAL.GetCosmeticCollection()
    if not collection then return false end
    collection.unlockedCosmetics[kind]=collection.unlockedCosmetics[kind] or {}
    collection.unlockedCosmetics[kind][name]=true
    return true
end

function RPAL.EquipCosmetic(kind,name)
    if not RPAL.IsCosmeticUnlocked(kind,name) then return false end
    RPAL.char.customization=RPAL.char.customization or {}
    if kind=="portrait" then RPAL.char.customization.portraitFrame=name
    elseif kind=="level" then RPAL.char.customization.levelBadge=name
    elseif kind=="header" then RPAL.char.customization.headerDecoration=name
    elseif kind=="background" then RPAL.char.customization.background=name
    elseif kind=="aura" then RPAL.char.customization.profileAura=name
    elseif kind=="ornament" then RPAL.char.customization.uiOrnament=name
    elseif kind=="effect" then RPAL.char.customization.effectPack=name
    elseif kind=="toast" then RPAL.char.customization.toastFrame=name
    elseif kind=="tooltip" then RPAL.char.customization.tooltipTreatment=name
    elseif kind=="focus" then RPAL.char.customization.focusMark=name
    elseif kind=="eye" then RPAL.char.customization.odynEye=name
    elseif kind=="platform" then RPAL.char.customization.platformStyle=name
    elseif kind=="animation" then
        if name=="Stand" then return false end
        RPAL.char.customization.characterAnimations=RPAL.char.customization.characterAnimations or {}
        RPAL.char.customization.characterAnimations[name]=true
        RPAL.char.customization.characterAnimation=name
    else return false end
    -- Cosmetic changes are visual-only.  Let UI.lua update the smallest
    -- affected surface instead of rebuilding the full ledger/statistics tree.
    if RPAL.OnCosmeticEquipped then
        RPAL.OnCosmeticEquipped(kind,name)
    elseif RPAL.Refresh then
        RPAL.Refresh()
    end
    return true
end

function RPAL.GetProfile()
    return RPAL.char.profile
end

function RPAL.GetLibrary()
    if RPAL.scope=="account" then
        return RPAL.db.accountLibrary
    end
    return RPAL.char.library
end

function RPAL.SetLibrary(library)
    if RPAL.scope=="account" then
        RPAL.db.accountLibrary=library
    else
        RPAL.SetTRPProfileScopedField("library",library)
    end
end

function RPAL.GetScopedProgressTable()
    if RPAL.scope=="account" then
        return RPAL.db.accountProgress
    end
    return RPAL.char.progress
end

function RPAL.SetScopedProgressTable(progress)
    if RPAL.scope=="account" then
        RPAL.db.accountProgress=progress
    else
        RPAL.SetTRPProfileScopedField("progress",progress)
    end
end

function RPAL.GetScopedFavourites()
    if RPAL.scope=="account" then
        return RPAL.db.accountFavourites
    end
    return RPAL.char.favourites
end

function RPAL.SetScopedFavourites(favourites)
    if RPAL.scope=="account" then
        RPAL.db.accountFavourites=favourites
    else
        RPAL.SetTRPProfileScopedField("favourites",favourites)
    end
end

function RPAL.GetPreviousLibrary()
    if RPAL.scope=="account" then
        return RPAL.db.accountPreviousLibrary
    end
    return RPAL.char.previousLibrary
end

function RPAL.SetPreviousLibrary(library)
    if RPAL.scope=="account" then
        RPAL.db.accountPreviousLibrary=library
    else
        RPAL.SetTRPProfileScopedField("previousLibrary",library)
    end
end

function RPAL.GetPreviousProgressTable()
    if RPAL.scope=="account" then return RPAL.db.accountPreviousProgress end
    return RPAL.char.previousProgress
end

function RPAL.SetPreviousProgressTable(progress)
    if RPAL.scope=="account" then RPAL.db.accountPreviousProgress=progress
    else RPAL.SetTRPProfileScopedField("previousProgress",progress) end
end

function RPAL.GetPreviousFavourites()
    if RPAL.scope=="account" then return RPAL.db.accountPreviousFavourites end
    return RPAL.char.previousFavourites
end

function RPAL.SetPreviousFavourites(favourites)
    if RPAL.scope=="account" then RPAL.db.accountPreviousFavourites=favourites
    else RPAL.SetTRPProfileScopedField("previousFavourites",favourites) end
end

function RPAL.SetScope(scope)
    if scope~="character" and scope~="account" then return end
    RPAL.scope=scope
    if RPAL.Refresh then RPAL.Refresh() end
end

function RPAL.FullName()
    local p = RPAL.char.profile
    local parts = {}
    if p.firstName ~= "" then table.insert(parts, p.firstName) end
    if p.surname ~= "" then table.insert(parts, p.surname) end
    if #parts == 0 then return UnitName("player") or "Unnamed Character" end
    return table.concat(parts, " ")
end

function RPAL.DisplayName()
    local base = RPAL.FullName()
    local pronouns = RPAL.char.profile.pronouns or ""
    if pronouns ~= "" then
        return base .. " (" .. pronouns .. ")"
    end
    return base
end

function RPAL.GuildDisplay()
    local p = RPAL.char.profile
    if p.guildRank ~= "" and p.guildName ~= "" then
        return p.guildRank .. " of " .. p.guildName
    elseif p.guildRank ~= "" then
        return p.guildRank
    elseif p.guildName ~= "" then
        return p.guildName
    end
    return ""
end

function RPAL.VisibleRace()
    local p = RPAL.char.profile
    if p.race == "Human" and p.kulTiran then return "Kul Tiran" end
    if p.easyMode and RPAL.EASY_RACES[p.race] then return RPAL.EASY_RACES[p.race] end
    return p.race or ""
end

function RPAL.ReplaceName(text)
    text = tostring(text or "")
    return text:gsub("%*name%*", RPAL.FullName())
end

function RPAL.FindCategory(id)
    return FindCategoryByID(RPAL.GetLibrary(), id)
end

function RPAL.FindBadge(categoryID, badgeID)
    return FindBadgeByID(RPAL.FindCategory(categoryID), badgeID)
end

function RPAL.GetProgress(badgeID)
    local progressTable=RPAL.GetScopedProgressTable()
    local p=progressTable[badgeID]
    if not p then
        p={completed=false,completedAt=nil,note=""}
        progressTable[badgeID]=p
    end
    return p
end

function RPAL.SetAchievementCompleted(categoryID,badgeID,completed)
    local badge=RPAL.FindBadge(categoryID,badgeID)
    if not badge then return false end

    local p=RPAL.GetProgress(badgeID)
    completed=completed and true or false

    -- No-op clicks stay no-op: Left on active and Right on inactive do nothing.
    if p.completed==completed then
        return true
    end

    p.completed=completed
    p.completedAt=completed and time() or nil

    if not completed then
        local fav=RPAL.GetScopedFavourites and RPAL.GetScopedFavourites() or RPAL.char.profile.favourites
        for i=#fav,1,-1 do
            if fav[i]==badgeID then table.remove(fav,i) end
        end
    end

    if RPAL.Refresh then RPAL.Refresh() end
    return true
end

function RPAL.ToggleAchievement(categoryID,badgeID)
    local p=RPAL.GetProgress(badgeID)
    return RPAL.SetAchievementCompleted(categoryID,badgeID,not p.completed)
end

function RPAL.ProgressTotals()
    local total, completed = 0, 0
    for _, category in ipairs(RPAL.GetLibrary()) do
        for _, badge in ipairs(category.badges or {}) do
            total = total + 1
            if RPAL.GetProgress(badge.id).completed then completed = completed + 1 end
        end
    end
    return completed, total
end

function RPAL.CategoryTotals(category)
    local total, completed = 0, 0
    for _, badge in ipairs(category.badges or {}) do
        total = total + 1
        if RPAL.GetProgress(badge.id).completed then completed = completed + 1 end
    end
    return completed, total
end

function RPAL.CompletedBadges()
    local out = {}
    for _, category in ipairs(RPAL.GetLibrary()) do
        for _, badge in ipairs(category.badges or {}) do
            if RPAL.GetProgress(badge.id).completed then
                table.insert(out, { category = category, badge = badge })
            end
        end
    end
    return out
end

function RPAL.GetBadgeByID(id)
    for _, category in ipairs(RPAL.GetLibrary()) do
        for _, badge in ipairs(category.badges or {}) do
            if badge.id == id then return badge, category end
        end
    end
end

function RPAL.HexEscape(s)
    s = tostring(s or "")
    s = s:gsub("%%", "%%25")
    s = s:gsub("|", "%%7C")
    s = s:gsub("\r", "%%0D")
    s = s:gsub("\n", "%%0A")
    return s
end

function RPAL.HexUnescape(s)
    s = tostring(s or "")
    s = s:gsub("%%0A", "\n")
    s = s:gsub("%%0D", "\r")
    s = s:gsub("%%7C", "|")
    s = s:gsub("%%25", "%%")
    return s
end

local function JoinLine(...)
    local t = {}
    for i = 1, select("#", ...) do
        t[i] = RPAL.HexEscape(select(i, ...))
    end
    return table.concat(t, "|")
end


local function SplitEscapedLine(line)
    local fields={}
    local start=1

    while true do
        local pos=string.find(line,"|",start,true)
        if not pos then
            table.insert(fields,string.sub(line,start))
            break
        end

        table.insert(fields,string.sub(line,start,pos-1))
        start=pos+1
    end

    for i=1,#fields do
        fields[i]=RPAL.HexUnescape(fields[i])
    end

    return fields
end


local function ByteHexEncode(value)
    local s=tostring(value or "")
    return (s:gsub(".", function(ch)
        return string.format("%02X", string.byte(ch))
    end))
end

local function ByteHexDecode(value)
    value=tostring(value or "")
    if value=="" then return "" end
    if (#value % 2) ~= 0 or value:find("[^0-9A-Fa-f]") then
        return nil
    end

    local out={}
    for i=1,#value,2 do
        local byte=tonumber(value:sub(i,i+1),16)
        if not byte then return nil end
        out[#out+1]=string.char(byte)
    end
    return table.concat(out)
end

local function SplitLiteral(line, separator)
    local fields={}
    local start=1
    while true do
        local pos=string.find(line,separator,start,true)
        if not pos then
            fields[#fields+1]=string.sub(line,start)
            break
        end
        fields[#fields+1]=string.sub(line,start,pos-1)
        start=pos+#separator
    end
    return fields
end

local function NormalizeImportedIcon(icon)
    if icon == nil or icon == "" then
        return RPAL.ICONS.question
    end

    -- Macro/icon picker commonly stores FileIDs. Export serializes them as text,
    -- so restore pure numeric values back to a Lua number for SetTexture().
    local numeric=tonumber(icon)
    if numeric then return numeric end

    return icon
end

function RPAL.ExportTemplate()
    local categoryCount=0
    local badgeCount=0

    for _,category in ipairs(RPAL.GetLibrary() or {}) do
        categoryCount=categoryCount+1
        badgeCount=badgeCount+#(category.badges or {})
    end

    local lines={
        ("RPALTPL3;%d;%d"):format(categoryCount,badgeCount)
    }

    for _,category in ipairs(RPAL.GetLibrary() or {}) do
        lines[#lines+1]=table.concat({
            "C",
            ByteHexEncode(category.id),
            ByteHexEncode(category.name),
            category.collapsed and "1" or "0",
        },";")

        for _,badge in ipairs(category.badges or {}) do
            local iconType=type(badge.icon)=="number" and "N" or "S"
            lines[#lines+1]=table.concat({
                "A",
                ByteHexEncode(category.id),
                ByteHexEncode(badge.id),
                ByteHexEncode(badge.name),
                ByteHexEncode(badge.description),
                iconType,
                ByteHexEncode(tostring(badge.icon or "")),
                ByteHexEncode(badge.additional),
            },";")
        end
    end

    return table.concat(lines,"\n")
end

local function ValidateImportedLibrary(library, expectedCategories, expectedBadges)
    if type(library)~="table" or #library==0 then
        return false,"Template contains no categories."
    end

    local catIDs={}
    local badgeIDs={}
    local categories=0
    local badges=0

    for _,category in ipairs(library) do
        categories=categories+1

        if not category.id or category.id=="" then
            return false,"A category has an empty ID."
        end
        if catIDs[category.id] then
            return false,"Template contains a duplicate category ID: "..category.id
        end
        catIDs[category.id]=true

        if not category.name or category.name=="" then
            return false,"A category has an empty name."
        end

        category.badges=category.badges or {}

        for _,badge in ipairs(category.badges) do
            badges=badges+1

            if not badge.id or badge.id=="" then
                return false,"An achievement has an empty ID."
            end
            if badgeIDs[badge.id] then
                return false,"Template contains a duplicate achievement ID: "..badge.id
            end
            badgeIDs[badge.id]=true

            badge.name=badge.name or ""
            badge.description=badge.description or ""
            badge.additional=badge.additional or ""
            badge.icon=NormalizeImportedIcon(badge.icon)
        end
    end

    if expectedCategories and categories~=expectedCategories then
        return false,("Template category count mismatch: expected %d, found %d."):format(expectedCategories,categories)
    end
    if expectedBadges and badges~=expectedBadges then
        return false,("Template achievement count mismatch: expected %d, found %d."):format(expectedBadges,badges)
    end

    return true,badgeIDs
end

local function ParseTemplateV3(text)
    local lines={}
    for line in text:gmatch("[^\r\n]+") do
        lines[#lines+1]=line
    end

    if #lines==0 then return nil,"Template is empty." end

    local header=SplitLiteral(lines[1],";")
    if header[1]~="RPALTPL3" then
        return nil,"Invalid RPALTPL3 header."
    end

    local expectedCategories=tonumber(header[2])
    local expectedBadges=tonumber(header[3])
    if not expectedCategories or not expectedBadges then
        return nil,"Invalid RPALTPL3 counts."
    end

    local library={}
    local catMap={}

    for i=2,#lines do
        local f=SplitLiteral(lines[i],";")

        if f[1]=="C" then
            if #f~=4 then
                return nil,"Malformed category record at line "..i.."."
            end

            local id=ByteHexDecode(f[2])
            local name=ByteHexDecode(f[3])
            if id==nil or name==nil then
                return nil,"Invalid encoded category data at line "..i.."."
            end

            if catMap[id] then
                return nil,"Duplicate category ID at line "..i.."."
            end

            local category={
                id=id,
                name=name,
                collapsed=(f[4]=="1"),
                badges={},
            }
            library[#library+1]=category
            catMap[id]=category

        elseif f[1]=="A" then
            if #f~=8 then
                return nil,"Malformed achievement record at line "..i.."."
            end

            local categoryID=ByteHexDecode(f[2])
            local badgeID=ByteHexDecode(f[3])
            local name=ByteHexDecode(f[4])
            local description=ByteHexDecode(f[5])
            local iconValue=ByteHexDecode(f[7])
            local additional=ByteHexDecode(f[8])

            if not categoryID or not badgeID or name==nil or description==nil or iconValue==nil or additional==nil then
                return nil,"Invalid encoded achievement data at line "..i.."."
            end

            local category=catMap[categoryID]
            if not category then
                return nil,"Achievement references an unknown category at line "..i.."."
            end

            local icon
            if f[6]=="N" then
                icon=tonumber(iconValue)
                if not icon then
                    return nil,"Invalid numeric icon FileID at line "..i.."."
                end
            elseif f[6]=="S" then
                icon=iconValue
            else
                return nil,"Invalid icon type at line "..i.."."
            end

            category.badges[#category.badges+1]={
                id=badgeID,
                name=name,
                description=description,
                icon=icon,
                additional=additional,
            }
        else
            return nil,"Unknown template record at line "..i.."."
        end
    end

    local ok,idsOrError=ValidateImportedLibrary(library,expectedCategories,expectedBadges)
    if not ok then return nil,idsOrError end

    return library
end

local function ParseTemplateV2(text)
    -- RPALTPL2 used | delimiters. WoW may transform |N into a newline when the
    -- export is displayed/copied, so this format is only accepted if it is still intact.
    local lines={}
    for line in text:gmatch("[^\r\n]+") do
        lines[#lines+1]=line
    end

    if #lines==0 then return nil,"Template is empty." end

    local header=SplitLiteral(lines[1],"|")
    if header[1]~="RPALTPL2" then return nil,"Invalid RPALTPL2 header." end

    local expectedCategories=tonumber(header[2])
    local expectedBadges=tonumber(header[3])
    if not expectedCategories or not expectedBadges then
        return nil,"Invalid RPALTPL2 counts."
    end

    local library={}
    local catMap={}

    for i=2,#lines do
        local f=SplitLiteral(lines[i],"|")
        if f[1]=="C" then
            if #f~=4 then
                return nil,"This RPALTPL2 export was corrupted by WoW text escapes. Re-export it with RPALTPL3."
            end
            local id=ByteHexDecode(f[2])
            local name=ByteHexDecode(f[3])
            if not id or name==nil then return nil,"Invalid RPALTPL2 category data." end
            local cat={id=id,name=name,collapsed=(f[4]=="1"),badges={}}
            library[#library+1]=cat
            catMap[id]=cat

        elseif f[1]=="A" then
            if #f~=8 then
                return nil,"This RPALTPL2 export was corrupted by WoW text escapes (usually |N). Re-export it with RPALTPL3."
            end

            local categoryID=ByteHexDecode(f[2])
            local badgeID=ByteHexDecode(f[3])
            local name=ByteHexDecode(f[4])
            local description=ByteHexDecode(f[5])
            local iconValue=ByteHexDecode(f[7])
            local additional=ByteHexDecode(f[8])
            local category=catMap[categoryID]

            if not category or not badgeID or name==nil or description==nil or iconValue==nil or additional==nil then
                return nil,"Invalid RPALTPL2 achievement data."
            end

            local icon=f[6]=="N" and tonumber(iconValue) or iconValue
            if f[6]~="N" and f[6]~="S" then
                return nil,"This RPALTPL2 export was corrupted by WoW text escapes. Re-export it with RPALTPL3."
            end

            category.badges[#category.badges+1]={
                id=badgeID,name=name,description=description,icon=icon,additional=additional,
            }
        else
            return nil,"This RPALTPL2 export was corrupted. Re-export it with RPALTPL3."
        end
    end

    local ok,err=ValidateImportedLibrary(library,expectedCategories,expectedBadges)
    if not ok then return nil,err end
    return library
end

local function ParseTemplateV1(text)
    local library={}
    local catMap={}

    for line in text:gmatch("[^\r\n]+") do
        if line~="RPALTPL1" then
            local f=SplitEscapedLine(line)

            if f[1]=="CAT" then
                if #f~=4 then return nil,"Malformed legacy category record." end
                local category={id=f[2],name=f[3],collapsed=(f[4]=="1"),badges={}}
                library[#library+1]=category
                catMap[category.id]=category

            elseif f[1]=="ACH" then
                if #f~=7 then return nil,"Malformed legacy achievement record." end
                local category=catMap[f[2]]
                if not category then return nil,"Legacy achievement references an unknown category." end
                category.badges[#category.badges+1]={
                    id=f[3],name=f[4],description=f[5],
                    icon=NormalizeImportedIcon(f[6]),additional=f[7],
                }
            else
                return nil,"Unknown legacy template record."
            end
        end
    end

    local ok,err=ValidateImportedLibrary(library)
    if not ok then return nil,err end
    return library
end

function RPAL.ImportTemplate(text)
    if type(text)~="string" then return false,"Invalid achievement template." end

    local library,err

    if text:find("^RPALTPL3") then
        library,err=ParseTemplateV3(text)
    elseif text:find("^RPALTPL2") then
        library,err=ParseTemplateV2(text)
    elseif text:find("^RPALTPL1") then
        library,err=ParseTemplateV1(text)
    else
        return false,"Unknown achievement template format."
    end

    if not library then
        return false,err or "Template validation failed."
    end

    -- Transactional: only replace the live library after full validation.
    RPAL.SetPreviousLibrary(RPAL.Copy(RPAL.GetLibrary()))
    RPAL.SetPreviousProgressTable(RPAL.Copy(RPAL.GetScopedProgressTable()))
    RPAL.SetPreviousFavourites(RPAL.Copy(RPAL.GetScopedFavourites()))
    RPAL.SetLibrary(library)

    local valid={}
    for _,category in ipairs(library) do
        for _,badge in ipairs(category.badges or {}) do
            valid[badge.id]=true
        end
    end

    local cleanedProgress={}
    for badgeID,progress in pairs(RPAL.GetScopedProgressTable() or {}) do
        if valid[badgeID] then cleanedProgress[badgeID]=progress end
    end
    RPAL.SetScopedProgressTable(cleanedProgress)

    local cleanedFavourites={}
    for _,badgeID in ipairs(RPAL.GetScopedFavourites() or {}) do
        if valid[badgeID] then cleanedFavourites[#cleanedFavourites+1]=badgeID end
    end
    RPAL.SetScopedFavourites(cleanedFavourites)

    if RPAL.Refresh then RPAL.Refresh() end
    return true
end

function RPAL.RestorePreviousTemplate()
    local previous=RPAL.GetPreviousLibrary()
    if type(previous)~="table" then
        return false,"No previous template backup is available for this tab."
    end

    local current=RPAL.Copy(RPAL.GetLibrary())
    local currentProgress=RPAL.Copy(RPAL.GetScopedProgressTable())
    local currentFavourites=RPAL.Copy(RPAL.GetScopedFavourites())
    local previousProgress=RPAL.GetPreviousProgressTable()
    local previousFavourites=RPAL.GetPreviousFavourites()
    RPAL.SetLibrary(RPAL.Copy(previous))
    RPAL.SetPreviousLibrary(current)
    if type(previousProgress)=="table" then
        RPAL.SetScopedProgressTable(RPAL.Copy(previousProgress))
        RPAL.SetPreviousProgressTable(currentProgress)
    end
    if type(previousFavourites)=="table" then
        RPAL.SetScopedFavourites(RPAL.Copy(previousFavourites))
        RPAL.SetPreviousFavourites(currentFavourites)
    end

    if RPAL.Refresh then RPAL.Refresh() end
    return true
end


RPAL.STAT_DEFINITIONS = {
    wave={label="/wave",command="/wave",weight=1},
    pet={label="/pet",command="/pet",weight=1},
    peer={label="/peer",command="/peer",weight=1},
    eye={label="/eye",command="/eye",weight=1},
    smirk={label="/smirk",command="/smirk",weight=1},
    snicker={label="/snicker",command="/snicker",weight=1},
    cry={label="/cry",command="/cry",weight=1},
    frown={label="/frown",command="/frown",weight=1},
    smile={label="/smile",command="/smile",weight=1},
    dance={label="/dance",command="/dance",weight=1},
    rude={label="/rude",command="/rude",weight=1},
    cheer={label="/cheer",command="/cheer",weight=1},
    chicken={label="/chicken",command="/chicken",weight=1},
    nod={label="/nod",command="/nod",weight=1},
    no={label="/no",command="/no",weight=1},
    shy={label="/shy",command="/shy",weight=1},
    hug={label="/hug",command="/hug",weight=1},
    flirt={label="/flirt",command="/flirt",weight=1},
    gasp={label="/gasp",command="/gasp",weight=1},
    bye={label="/bye",command="/bye",weight=1},
    yawn={label="/yawn",command="/yawn",weight=1},
    glance={label="/glance",command="/glance",weight=1},
    hiss={label="/hiss",command="/hiss",weight=1},
    point={label="/point",command="/point",weight=1},
    sniff={label="/sniff",command="/sniff",weight=1},
}
-- Ordered roughly by the emotes most commonly used in RP. This also keeps the
-- most relevant statistics visually prominent in profile/inspect grids.
-- /bye is intentionally checked before /wave because Blizzard's /bye text
-- includes "waves goodbye" on the English client.
RPAL.STAT_ORDER={"nod","peer","shy","smile","flirt","snicker","smirk","pet","hug","eye","gasp","frown","bye","wave","cheer","chicken","dance","cry","yawn","no","rude","glance","hiss","point","sniff"}
RPAL.CHARACTERS_MET_SCORE=7

-- ------------------------------------------------------------
-- DM Campaign participation / completed campaign memories
-- ------------------------------------------------------------
local function EnsureDMCampaignMemory()
    local scope=RPAL.GetCurrentTRPProfileScope and RPAL.GetCurrentTRPProfileScope()
    if not scope then
        RPAL.char=RPAL.char or {}
        RPAL.char.dmCampaigns=type(RPAL.char.dmCampaigns)=="table" and RPAL.char.dmCampaigns or {records={},order={}}
        return RPAL.char.dmCampaigns
    end
    scope.dmCampaigns=type(scope.dmCampaigns)=="table" and scope.dmCampaigns or {records={},order={}}
    scope.dmCampaigns.records=type(scope.dmCampaigns.records)=="table" and scope.dmCampaigns.records or {}
    scope.dmCampaigns.order=type(scope.dmCampaigns.order)=="table" and scope.dmCampaigns.order or {}
    if RPAL.char then RPAL.char.dmCampaigns=scope.dmCampaigns end
    return scope.dmCampaigns
end

function RPAL.RegisterDMCampaignJoined(campaignID,campaignName,host)
    campaignID=tostring(campaignID or "")
    if campaignID=="" then return false end
    local stats=RPAL.GetStatistics and RPAL.GetStatistics()
    if not stats then return false end
    stats.joinedCampaignIDs=type(stats.joinedCampaignIDs)=="table" and stats.joinedCampaignIDs or {}
    local key=(tostring(host or ""):lower().."::"..campaignID)
    if stats.joinedCampaignIDs[key] then return false end
    stats.joinedCampaignIDs[key]={name=tostring(campaignName or "Campaign"),joinedAt=time(),host=tostring(host or "")}
    stats.campaignsJoined=math.max(0,math.floor(tonumber(stats.campaignsJoined) or 0))+1
    if RPAL.OnStatisticsChanged then RPAL.OnStatisticsChanged("campaign_joined",true,"campaign") end
    return true,stats.campaignsJoined
end

function RPAL.GetDMCampaignRecords()
    local store=EnsureDMCampaignMemory()
    local out,seen={},{}
    for _,id in ipairs(store.order or {}) do
        local item=store.records and store.records[id]
        if item then out[#out+1]=item;seen[id]=true end
    end
    for id,item in pairs(store.records or {}) do if not seen[id] then out[#out+1]=item end end
    table.sort(out,function(a,b)return (tonumber(a.concludedAt) or 0)>(tonumber(b.concludedAt) or 0) end)
    return out
end

function RPAL.RecordConcludedDMCampaign(data)
    if type(data)~="table" then return false end
    local campaignID=tostring(data.campaignID or "")
    local host=tostring(data.host or "")
    if campaignID=="" then return false end
    local store=EnsureDMCampaignMemory()
    local stable=(host:lower().."::"..campaignID)
    local existingID
    for id,item in pairs(store.records or {}) do
        if tostring(item.stableKey or "")==stable then existingID=id break end
    end
    local id=existingID or ("campaign-memory-"..tostring(time()).."-"..tostring(math.random(1000,9999)))
    local old=store.records[id] or {}
    local participants={}
    for _,p in ipairs(type(data.participants)=="table" and data.participants or type(old.participants)=="table" and old.participants or {}) do
        participants[#participants+1]={name=tostring(p.name or ""):sub(1,96),trp=tostring(p.trp or ""):sub(1,80),isHost=p.isHost and true or false}
    end
    store.records[id]={
        id=id,stableKey=stable,campaignID=campaignID,
        name=tostring(data.name or old.name or "Campaign"):sub(1,80),
        host=host~="" and host or tostring(old.host or ""),
        hostTRP=tostring(data.hostTRP or old.hostTRP or ""):sub(1,80),
        startedAt=tonumber(data.startedAt) or old.startedAt,
        concludedAt=tonumber(data.concludedAt) or time(),
        duration=math.max(0,math.floor(tonumber(data.duration) or tonumber(old.duration) or 0)),
        renownEarned=math.max(0,math.floor(tonumber(data.renownEarned) or tonumber(old.renownEarned) or 0)),
        participants=participants,
        npcsInteracted=math.max(0,math.floor(tonumber(data.npcsInteracted) or tonumber(old.npcsInteracted) or 0)),
        npcsKilled=math.max(0,math.floor(tonumber(data.npcsKilled) or tonumber(old.npcsKilled) or 0)),
        npcsSaved=math.max(0,math.floor(tonumber(data.npcsSaved) or tonumber(old.npcsSaved) or 0)),
        rollMin=tonumber(data.rollMin) or tonumber(old.rollMin),
        rollMax=tonumber(data.rollMax) or tonumber(old.rollMax),
        sessionCount=math.max(0,math.floor(tonumber(data.sessionCount) or tonumber(old.sessionCount) or 0)),
        pinned=old.pinned and true or false,
        readOnly=true,
    }
    if not existingID then table.insert(store.order,1,id) end
    while #store.order>60 do
        local drop=table.remove(store.order)
        if drop then store.records[drop]=nil end
    end
    return true,store.records[id]
end

function RPAL.SetDMCampaignPinned(id,wanted)
    local store=EnsureDMCampaignMemory()
    local item=store.records and store.records[id]
    if not item then return false,"Campaign record not found." end
    wanted=wanted and true or false
    if wanted and not item.pinned then
        local n=0
        for _,entry in pairs(store.records or {}) do if entry.pinned then n=n+1 end end
        if n>=3 then return false,"You can pin up to 3 Campaigns." end
    end
    item.pinned=wanted
    return true
end

function RPAL.GetPinnedDMCampaigns()
    local out={}
    for _,item in ipairs(RPAL.GetDMCampaignRecords()) do if item.pinned then out[#out+1]=item end end
    return out
end


-- Renown progression. Curve v5 keeps Level 60 as the character's
-- pseudo-endgame / soft cap. Levels 1-30 remain approachable, 30-35 begins
-- slowing down, 35-40 makes the slowdown clearly visible, and the climb then
-- steepens continuously through 40-60. Levels above 60 are prestige territory
-- and deliberately become dramatically slower.
--
-- As with curve v3, earned Renown is never fabricated during migration. If the
-- steeper curve lowers a character's derived Level, the bounded Session catch-up
-- below is granted instead.
RPAL.RENOWN_CURVE_VERSION=5
RPAL.RENOWN_CURVE_CATCHUP_PERCENT=15
RPAL.RENOWN_CURVE_CATCHUP_SESSIONS=10

local function RoundRenown5(value)
    return math.max(5,math.floor((value+2.5)/5)*5)
end

local function LegacyRenownRequiredForLevel(level)
    level=math.max(1,math.floor(tonumber(level) or 1))
    local raw
    if level<=30 then
        raw=10+((level-1)*(40/29))
    elseif level<=60 then
        raw=50+((level-30)*(50/30))
    else
        raw=(100+((level-60)*5))*2
    end
    return RoundRenown5(raw)
end

function RPAL.RenownRequiredForLevelV2(level)
    local legacy=LegacyRenownRequiredForLevel(level)
    return math.max(1,math.floor((legacy*(4/3))+0.5))
end

-- Curve v3 is kept intact so an existing v3 character can be migrated to v4
-- from the level they actually had immediately before this update.
local RENOWN_DIFFICULTY_TIERS_V3={
    {from=85,multiplier=3.00},
    {from=80,multiplier=2.50},
    {from=70,multiplier=2.00},
    {from=60,multiplier=1.50},
    {from=45,multiplier=1.35},
    {from=30,multiplier=1.25},
    {from=15,multiplier=1.10},
    {from=1,multiplier=1.00},
}

local function RenownDifficultyMultiplierV3(level)
    level=math.max(1,math.floor(tonumber(level) or 1))
    for _,tier in ipairs(RENOWN_DIFFICULTY_TIERS_V3) do
        if level>=tier.from then return tier.multiplier end
    end
    return 1
end

function RPAL.RenownRequiredForLevelV3(level)
    level=math.max(1,math.floor(tonumber(level) or 1))
    return math.max(1,math.floor((RPAL.RenownRequiredForLevelV2(level)*RenownDifficultyMultiplierV3(level))+0.5))
end

-- Exact v4 next-level costs. The totals are intentionally hand-shaped instead
-- of multiplying the old curve so the pacing can hit the desired milestones:
-- L10 300, L20 1,200, L30 3,000, L35 4,500, L40 6,500,
-- L45 9,000, L50 13,000, L55 19,000, L60 30,000,
-- L65 50,000, L70 75,000, L80 140,000, L90 240,000,
-- L100 380,000 total Renown.
local RENOWN_REQUIREMENTS_V4={
    -- 1 -> 10: onboarding / fast progression (300 total)
    15,20,25,30,35,35,40,45,55,
    -- 10 -> 20: still relaxed (900 / 1,200 cumulative)
    60,65,70,80,85,95,100,105,115,125,
    -- 20 -> 30: established progression (1,800 / 3,000 cumulative)
    130,140,150,160,170,180,190,200,220,260,
    -- 30 -> 35: first slowdown (1,500 / 4,500 cumulative)
    270,285,300,315,330,
    -- 35 -> 40: slowdown becomes obvious (2,000 / 6,500 cumulative)
    350,375,400,425,450,
    -- 40 -> 45: veteran climb begins (2,500 / 9,000 cumulative)
    460,480,500,520,540,
    -- 45 -> 50: levels become meaningfully expensive (4,000 / 13,000 cumulative)
    600,700,800,900,1000,
    -- 50 -> 55: late-game approach (6,000 / 19,000 cumulative)
    1050,1125,1200,1275,1350,
    -- 55 -> 60: endgame climb (11,000 / 30,000 cumulative)
    1500,1850,2200,2550,2900,
    -- 60 -> 65: pseudo-endgame prestige wall (20,000 / 50,000 cumulative)
    3300,3650,4000,4350,4700,
    -- 65 -> 70: prestige (25,000 / 75,000 cumulative)
    4800,4900,5000,5100,5200,
    -- 70 -> 80: long-term veteran prestige (65,000 / 140,000 cumulative)
    5400,5650,5900,6150,6400,6600,6850,7100,7350,7600,
    -- 80 -> 90: exceptional longevity (100,000 / 240,000 cumulative)
    8000,8450,8900,9350,9800,10200,10650,11100,11550,12000,
    -- 90 -> 100: extreme prestige (140,000 / 380,000 cumulative)
    12500,12850,13200,13550,13900,14100,14450,14800,15150,15500,
}

function RPAL.RenownRequiredForLevelV4(level)
    level=math.max(1,math.floor(tonumber(level) or 1))
    local exact=RENOWN_REQUIREMENTS_V4[level]
    if exact then return exact end
    return 16000+((level-100)*500)
end

-- Curve v5 is a feel/pacing pass rather than another wall.  The whole curve is
-- roughly 10% lighter and the onboarding stretch through Level 30 receives an
-- additional reduction, while the 55-60 soft-cap climb and 60+ prestige shape
-- remain intact.  Costs stay rounded to clean 5-Renown steps.
function RPAL.RenownRequiredForLevel(level)
    level=math.max(1,math.floor(tonumber(level) or 1))
    local previous=RPAL.RenownRequiredForLevelV4(level)
    local multiplier=(level<=29) and 0.80 or 0.90
    return RoundRenown5(previous*multiplier)
end

-- Retain the public compatibility helper. In v5 it reports the real
-- ratio against the pre-band v2 requirement instead of selecting a fixed tier.
function RPAL.GetRenownDifficultyMultiplier(level)
    level=math.max(1,math.floor(tonumber(level) or 1))
    local baseline=math.max(1,RPAL.RenownRequiredForLevelV2(level))
    local multiplier=RPAL.RenownRequiredForLevel(level)/baseline
    return multiplier,string.format("x%.2f",multiplier),level
end

local function RenownLevelDataWithRequirement(totalRenown,requirement)
    local remaining=math.max(0,math.floor(tonumber(totalRenown) or 0))
    local level,spent,safety=1,0,0
    while safety<1000 do
        local needed=requirement(level)
        if remaining<needed then return level,remaining,needed,spent end
        remaining=remaining-needed;spent=spent+needed;level=level+1;safety=safety+1
    end
    return level,remaining,requirement(level),spent
end

function RPAL.MigrateRenownCurve(statistics)
    if type(statistics)~="table" then return false end
    local currentVersion=math.max(0,math.floor(tonumber(statistics.progressionCurveVersion) or 1))
    if currentVersion>=RPAL.RENOWN_CURVE_VERSION then return false end

    local originalScore=math.max(0,math.floor(tonumber(statistics.score) or 0))
    local existingCurveCatchup=type(statistics.renownCurveCatchup)=="table" and statistics.renownCurveCatchup or nil

    -- Very old saves can arrive without the v2 stamp. First reproduce the
    -- prior level-preserving v1 -> v2 conversion, then apply the current economy.
    if currentVersion<2 then
        local level,oldProgress,oldNeeded=RenownLevelDataWithRequirement(originalScore,LegacyRenownRequiredForLevel)
        local v2Spent=0
        for current=1,level-1 do v2Spent=v2Spent+RPAL.RenownRequiredForLevelV2(current) end
        local v2Needed=RPAL.RenownRequiredForLevelV2(level)
        local progressRatio=oldNeeded>0 and (oldProgress/oldNeeded) or 0
        local v2Progress=math.min(v2Needed-1,math.max(0,math.floor((progressRatio*v2Needed)+0.5)))
        statistics.score=v2Spent+v2Progress
        currentVersion=2
    else
        statistics.score=originalScore
    end

    local migrationScore=math.max(0,math.floor(tonumber(statistics.score) or 0))
    local previousRequirement=RPAL.RenownRequiredForLevelV2
    if currentVersion==3 then previousRequirement=RPAL.RenownRequiredForLevelV3
    elseif currentVersion==4 then previousRequirement=RPAL.RenownRequiredForLevelV4 end
    local oldLevel,oldProgress,oldNeeded=RenownLevelDataWithRequirement(migrationScore,previousRequirement)
    local newLevel,newProgress,newNeeded=RenownLevelDataWithRequirement(migrationScore,RPAL.RenownRequiredForLevel)
    local lost=math.max(0,oldLevel-newLevel)

    statistics.progressionCurveVersion=RPAL.RENOWN_CURVE_VERSION
    statistics.renownCurveMigratedAt=time and time() or 0
    statistics.renownCurvePreviousLevel=oldLevel
    statistics.renownCurveMigratedLevel=newLevel
    statistics.renownCurveMigrationNotice={
        version=RPAL.RENOWN_CURVE_VERSION,oldLevel=oldLevel,newLevel=newLevel,
        oldProgress=oldProgress,oldNeeded=oldNeeded,newProgress=newProgress,newNeeded=newNeeded,
        score=migrationScore,shown=false,createdAt=time and time() or 0,
    }
    if lost>0 then
        statistics.renownCurveCatchup={
            version=RPAL.RENOWN_CURVE_VERSION,
            percent=RPAL.RENOWN_CURVE_CATCHUP_PERCENT,
            sessionsTotal=RPAL.RENOWN_CURVE_CATCHUP_SESSIONS,
            sessionsRemaining=RPAL.RENOWN_CURVE_CATCHUP_SESSIONS,
            remainder=0,
            oldLevel=oldLevel,
            newLevel=newLevel,
            levelsLost=lost,
            grantedAt=time and time() or 0,
        }
    elseif existingCurveCatchup and (tonumber(existingCurveCatchup.sessionsRemaining) or 0)>0 then
        -- An easier curve must not silently delete a catch-up boost that the
        -- character still had remaining from the previous progression pass.
        existingCurveCatchup.version=RPAL.RENOWN_CURVE_VERSION
        statistics.renownCurveCatchup=existingCurveCatchup
    else
        statistics.renownCurveCatchup=nil
    end
    if RPAL.OnSessionRenownBoostChanged then pcall(RPAL.OnSessionRenownBoostChanged,"curve_migration") end
    return true,{
        oldScore=originalScore,newScore=statistics.score,
        oldLevel=oldLevel,newLevel=newLevel,levelsLost=lost,
    }
end

function RPAL.GetRenownLevelData(totalRenown)
    return RenownLevelDataWithRequirement(totalRenown,RPAL.RenownRequiredForLevel)
end

function RPAL.GetCurrentRenownLevel()
    local stats=RPAL.GetStatistics and RPAL.GetStatistics()
    return RPAL.GetRenownLevelData(stats and stats.score or 0)
end

function RPAL.GetRenownForLevel(level)
    level=math.max(1,math.floor(tonumber(level) or 1))
    local total=0
    for current=1,level-1 do total=total+RPAL.RenownRequiredForLevel(current) end
    return total
end

function RPAL.GetRenownCurveCatchup()
    local stats=RPAL.GetStatistics and RPAL.GetStatistics() or (RPAL.char and RPAL.char.statistics)
    local catchup=stats and stats.renownCurveCatchup
    if type(catchup)~="table" or tonumber(catchup.version)~=RPAL.RENOWN_CURVE_VERSION then return nil end
    catchup.percent=math.max(0,tonumber(catchup.percent) or RPAL.RENOWN_CURVE_CATCHUP_PERCENT)
    catchup.sessionsTotal=math.max(1,math.floor(tonumber(catchup.sessionsTotal) or RPAL.RENOWN_CURVE_CATCHUP_SESSIONS))
    catchup.sessionsRemaining=math.max(0,math.floor(tonumber(catchup.sessionsRemaining) or 0))
    catchup.remainder=math.max(0,tonumber(catchup.remainder) or 0)
    return catchup
end

function RPAL.ConsumeRenownCurveCatchupSession(scene)
    local catchup=RPAL.GetRenownCurveCatchup and RPAL.GetRenownCurveCatchup()
    if not catchup or catchup.sessionsRemaining<=0 or not (scene and scene.renownCurveCatchupApplied) then return false end
    local sceneID=tostring(scene.id or "")
    if sceneID~="" and tostring(catchup.lastConsumedSceneID or "")==sceneID then return false,"already_consumed" end
    catchup.sessionsRemaining=math.max(0,catchup.sessionsRemaining-1)
    catchup.lastConsumedSceneID=sceneID~="" and sceneID or nil
    catchup.lastConsumedAt=time and time() or 0
    if catchup.sessionsRemaining<=0 then catchup.completedAt=catchup.lastConsumedAt end
    if RPAL.RefreshSeasonStrip then pcall(RPAL.RefreshSeasonStrip,"curve_catchup") end
    if RPAL.OnSessionRenownBoostChanged then pcall(RPAL.OnSessionRenownBoostChanged,"curve_consumed") end
    return true,catchup.sessionsRemaining
end

-- ------------------------------------------------------------
-- Main roleplay character
-- ------------------------------------------------------------
RPAL.MAIN_CHARACTER_COOLDOWN=30*24*60*60

local function CurrentWoWCharacterKey()
    local guid=UnitGUID and UnitGUID("player")
    if guid and guid~="" then return tostring(guid) end
    local name,realm=UnitFullName and UnitFullName("player")
    return tostring(name or (UnitName and UnitName("player")) or "Unknown").."-"..tostring(realm or (GetRealmName and GetRealmName()) or "")
end

function RPAL.GetCurrentCharacterIdentityKey()
    local profileID=(RPAL.GetActiveTRPProfileID and RPAL.GetActiveTRPProfileID()) or RPAL._activeTRPProfileScopeID or TRP_FALLBACK_PROFILE_ID
    return CurrentWoWCharacterKey().."::"..tostring(profileID),profileID
end

function RPAL.GetMainCharacterInfo()
    if not RPAL.db then return nil end
    return type(RPAL.db.mainCharacter)=="table" and RPAL.db.mainCharacter or nil
end

function RPAL.IsCurrentCharacterMain()
    local main=RPAL.GetMainCharacterInfo()
    local identity=RPAL.GetCurrentCharacterIdentityKey()
    return main and main.identityKey==identity or false
end

function RPAL.GetMainCharacterCooldownRemaining()
    local main=RPAL.GetMainCharacterInfo()
    return math.max(0,math.floor(tonumber(main and main.cooldownUntil) or 0)-time())
end

function RPAL.SetCurrentCharacterAsMain()
    if not RPAL.db or not RPAL.char then return false,"Character data is not ready." end
    local identity,profileID=RPAL.GetCurrentCharacterIdentityKey()
    local previous=RPAL.GetMainCharacterInfo()
    if previous and previous.identityKey==identity then return false,"This is already your Main character." end
    local remaining=RPAL.GetMainCharacterCooldownRemaining()
    if previous and remaining>0 then return false,"Main character can be changed again after the 30-day cooldown." end

    local now=time()
    local displayName=(RPAL.FullName and RPAL.FullName()) or (UnitName and UnitName("player")) or "This character"
    RPAL.db.mainCharacter={
        version=1,identityKey=identity,profileID=profileID,wowCharacterKey=CurrentWoWCharacterKey(),
        displayName=displayName,selectedAt=now,cooldownUntil=now+RPAL.MAIN_CHARACTER_COOLDOWN,
    }
    if RPAL.UnlockCosmetic then RPAL.UnlockCosmetic("level","Captain's Radiance","main") end
    if RPAL.EquipCosmetic then RPAL.EquipCosmetic("level","Captain's Radiance") end
    if RPAL.OnMainCharacterSelected then RPAL.OnMainCharacterSelected(RPAL.db.mainCharacter,previous) end
    return true,RPAL.db.mainCharacter
end

-- ------------------------------------------------------------
-- One-time legacy calibration per TRP profile
-- ------------------------------------------------------------
RPAL.LEGACY_CALIBRATION_TIERS={
    fresh={label="Fresh",level=1,catchupPercent=0,catchupSessions=0},
    established={label="Established",level=5,catchupPercent=10,catchupSessions=8},
    veteran={label="Veteran",level=10,catchupPercent=15,catchupSessions=12},
}

function RPAL.GetLegacyCalibration()
    local scope=RPAL.GetCurrentTRPProfileScope and RPAL.GetCurrentTRPProfileScope()
    if not scope then return nil end
    scope.legacyCalibration=type(scope.legacyCalibration)=="table" and scope.legacyCalibration or {completed=false}
    RPAL.char.legacyCalibration=scope.legacyCalibration
    return scope.legacyCalibration
end

function RPAL.ApplyLegacyCalibration(tierKey)
    tierKey=tostring(tierKey or ""):lower()
    local tier=RPAL.LEGACY_CALIBRATION_TIERS[tierKey]
    local calibration=RPAL.GetLegacyCalibration()
    if not tier or not calibration then return false,"That calibration choice is unavailable." end
    if calibration.completed then return false,"Legacy Calibration has already been completed for this TRP profile." end

    local stats=RPAL.GetStatistics and RPAL.GetStatistics()
    if not stats then return false,"Renown data is not ready." end
    local oldLevel=select(1,RPAL.GetRenownLevelData(stats.score or 0))
    local target=RPAL.GetRenownForLevel(tier.level)
    stats.score=math.max(math.floor(tonumber(stats.score) or 0),target)
    local newLevel=select(1,RPAL.GetRenownLevelData(stats.score or 0))
    calibration.completed=true
    calibration.tier=tierKey
    calibration.completedAt=time()
    calibration.baselineLevel=tier.level
    calibration.catchupPercent=tier.catchupPercent
    calibration.catchupSessionsRemaining=tier.catchupSessions
    calibration.catchupRemainder=0
    if RPAL.OnSessionRenownBoostChanged then pcall(RPAL.OnSessionRenownBoostChanged,"legacy_granted") end

    local rewards=RPAL.GrantLevelRewards and RPAL.GrantLevelRewards(oldLevel,newLevel) or {}
    if tierKey~="fresh" then
        if RPAL.UnlockCosmetic then RPAL.UnlockCosmetic("level","Legacy Chronicle","legacy") end
        if RPAL.EquipCosmetic then RPAL.EquipCosmetic("level","Legacy Chronicle") end
        rewards[#rewards+1]={level=newLevel,kind="level",name="Legacy Chronicle",source="legacy"}
    end
    if newLevel>oldLevel and RPAL.OnRenownLevelUp then RPAL.OnRenownLevelUp(newLevel,oldLevel,"legacy_calibration",rewards) end
    if RPAL.OnLegacyCalibrationApplied then RPAL.OnLegacyCalibrationApplied(calibration,tier,rewards) end
    if RPAL.OnStatisticsChanged then RPAL.OnStatisticsChanged("legacy_calibration",true,"legacy") end
    return true,{tier=tierKey,oldLevel=oldLevel,newLevel=newLevel,rewards=rewards,calibration=calibration}
end

function RPAL.ConsumeLegacyCatchupSession(scene)
    local calibration=RPAL.GetLegacyCalibration()
    if not calibration or not calibration.completed then return false end
    local remaining=math.max(0,math.floor(tonumber(calibration.catchupSessionsRemaining) or 0))
    if remaining<=0 or math.max(0,tonumber(scene and scene.renownGained) or 0)<=0 then return false end
    calibration.catchupSessionsRemaining=remaining-1
    if RPAL.OnSessionRenownBoostChanged then pcall(RPAL.OnSessionRenownBoostChanged,"legacy_consumed") end
    return true,calibration.catchupSessionsRemaining
end

-- Returns every currently active Session-based Renown accelerator in a UI-safe
-- shape.  Multiple sources add together in AddRenown, so expose both the total
-- and the individual remaining-Session counters rather than hiding a shorter
-- boost behind a single ambiguous duration.
function RPAL.GetSessionRenownBoostInfo()
    local sources={}
    local calibration=RPAL.GetLegacyCalibration and RPAL.GetLegacyCalibration()
    if calibration and calibration.completed then
        local percent=math.max(0,tonumber(calibration.catchupPercent) or 0)
        local remaining=math.max(0,math.floor(tonumber(calibration.catchupSessionsRemaining) or 0))
        if percent>0 and remaining>0 then
            sources[#sources+1]={key="legacy",label="Legacy Calibration",percent=percent,sessionsRemaining=remaining}
        end
    end

    local curve=RPAL.GetRenownCurveCatchup and RPAL.GetRenownCurveCatchup()
    if curve then
        local percent=math.max(0,tonumber(curve.percent) or 0)
        local remaining=math.max(0,math.floor(tonumber(curve.sessionsRemaining) or 0))
        if percent>0 and remaining>0 then
            sources[#sources+1]={key="curve",label="Progression Catch-up",percent=percent,sessionsRemaining=remaining}
        end
    end

    if #sources==0 then return nil end
    local totalPercent=0
    for _,entry in ipairs(sources) do totalPercent=totalPercent+(tonumber(entry.percent) or 0) end
    return {percent=totalPercent,sources=sources}
end

-- Progression cosmetics are visible in Customize from the beginning, but can
-- only be earned from their declared source.  Badge milestones mirror the
-- seven colors used by the Renown level display.
RPAL.FUTURE_COSMETIC_LOOKUP={
    ["theme:Blood"]={label="FALL 2026 • PART II • COMING SOON"},
    ["theme:Silvermoon"]={label="FALL 2026 • PART II • COMING SOON"},
    ["theme:Nightborne"]={label="FALL 2026 • PART II • COMING SOON"},
    ["background:Zodiac Veil"]={label="FALL 2026 • PART II • COMING SOON"},
    ["background:Grave Lanterns"]={label="FALL 2026 • PART II • COMING SOON"},
    ["portrait:Bloodthorn"]={label="FALL 2026 • PART II • COMING SOON"},
    ["header:Blood"]={label="FALL 2026 • PART II • COMING SOON"},
    ["aura:Astral Crown"]={label="FALL 2026 • PART II • COMING SOON"},
    ["platform:Moonwell Bloom"]={label="FALL 2026 • PART II • COMING SOON"},
    ["effect:Gilded Storm"]={label="FALL 2026 • PART II • COMING SOON"},
    ["effect:Crimson Cinders"]={label="FALL 2026 • PART II • COMING SOON"},
    ["tooltip:Heirloom Marginalia"]={label="FALL 2026 • PART II • COMING SOON"},
    ["focus:Heirloom Compass"]={label="FALL 2026 • PART II • COMING SOON"},
    ["ornament:Heirloom Folio"]={label="FALL 2026 • PART II • COMING SOON"},
    ["eye:Maplefall Eye"]={label="FALL 2026 • PART II • COMING SOON"},
    ["eye:Solar Watch"]={label="FALL 2026 • PART II • COMING SOON"},
}

RPAL.LEVEL_REWARDS={
    {level=1,kind="level",name="Initiate Seal"},
    {level=5,kind="level",name="Verdant Mark"},
    {level=8,kind="portrait",name="Gilded"},
    {level=12,kind="header",name="Kaldorei"},
    {level=15,kind="level",name="Azure Sigil"},
    {level=20,kind="theme",name="Cobalt Arc"},
    {level=25,kind="effect",name="Fireflies"},
    {level=30,kind="level",name="Amethyst Crest"},
    {level=34,kind="ornament",name="Frost Runes"},
    {level=35,kind="eye",name="Emberwatch"},
    {level=40,kind="level",name="Rose Emblem"},
    {level=44,kind="aura",name="Moonlit"},
    {level=50,kind="level",name="Sunforged Seal"},
    {level=54,kind="header",name="Ember"},
    {level=55,kind="eye",name="Silver Thread"},
    {level=60,kind="level",name="Crimson Legend"},
    {level=65,kind="theme",name="Solar Flare"},
}

local function UnlockProgressionReward(reward)
    if not reward then return false end
    if reward.kind=="theme" then
        local wasUnlocked=RPAL.IsThemeUnlocked and RPAL.IsThemeUnlocked(reward.name)
        if RPAL.UnlockTheme then RPAL.UnlockTheme(reward.name,"progression") end
        return not wasUnlocked
    end
    local wasUnlocked=RPAL.IsCosmeticUnlocked and RPAL.IsCosmeticUnlocked(reward.kind,reward.name)
    if RPAL.UnlockCosmetic then RPAL.UnlockCosmetic(reward.kind,reward.name,"progression") end
    return not wasUnlocked
end

function RPAL.GetProgressionUnlockSource(kind,name)
    for _,reward in ipairs(RPAL.LEVEL_REWARDS or {}) do
        if reward.kind==kind and reward.name==name then
            return {source="level",level=reward.level,label="LEVEL "..tostring(reward.level)}
        end
    end
    local atlas=RPAL.ATLAS_REWARD_LOOKUP and RPAL.ATLAS_REWARD_LOOKUP[kind..":"..tostring(name)]
    if atlas then return {source="atlas",node=atlas.node,label=atlas.label or "RENOWN ATLAS"} end
    local season=RPAL.SEASON_REWARD_LOOKUP and RPAL.SEASON_REWARD_LOOKUP[kind..":"..tostring(name)]
    if season then return {source="season",season=season.season,level=season.level,label=season.label or "SEASON REWARD"} end
    local achievement=RPAL.ACHIEVEMENT_REWARD_LOOKUP and RPAL.ACHIEVEMENT_REWARD_LOOKUP[kind..":"..tostring(name)]
    if achievement then return {source="achievement",achievement=achievement.achievement,label=achievement.label or "ACHIEVEMENT REWARD"} end
    local future=RPAL.FUTURE_COSMETIC_LOOKUP and RPAL.FUTURE_COSMETIC_LOOKUP[kind..":"..tostring(name)]
    if future then return {source="future",label=future.label or "COMING SOON"} end
    return nil
end

function RPAL.IsProgressionCosmeticLocked(unlockSource,unlocked)
    if unlocked or not unlockSource then return false end
    if unlockSource.source=="season" or unlockSource.source=="future" then return true end
    return true
end

function RPAL.GrantLevelRewards(oldLevel,newLevel)
    oldLevel=math.max(0,math.floor(tonumber(oldLevel) or 0))
    newLevel=math.max(1,math.floor(tonumber(newLevel) or 1))
    local granted={}
    for _,reward in ipairs(RPAL.LEVEL_REWARDS or {}) do
        if reward.level>oldLevel and reward.level<=newLevel and UnlockProgressionReward(reward) then
            granted[#granted+1]={level=reward.level,kind=reward.kind,name=reward.name}
        end
    end
    return granted
end

function RPAL.ReconcileLevelRewards()
    local level=RPAL.GetCurrentRenownLevel and select(1,RPAL.GetCurrentRenownLevel()) or 1
    RPAL.GrantLevelRewards(0,level)
    local selected=RPAL.char and RPAL.char.customization and RPAL.char.customization.levelBadge
    local valid={
        ["Initiate Seal"]=true,["Verdant Mark"]=true,["Azure Sigil"]=true,
        ["Amethyst Crest"]=true,["Rose Emblem"]=true,["Sunforged Seal"]=true,
        ["Crimson Legend"]=true,["Legacy Chronicle"]=true,["Captain's Radiance"]=true,["Open Beta Crest"]=true,
    }
    if not valid[selected] or not RPAL.IsCosmeticUnlocked("level",selected) then
        RPAL.char.customization.levelBadge="Initiate Seal"
    end
end

function RPAL.AddRenown(amount,source,options)
    -- Lite Kit deliberately keeps RPAL's roleplay tools while opting out of
    -- the progression economy. Do not mutate Renown, level, Tokens/Crests or
    -- progression rewards while the account is in Lite mode.
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return 0 end
    local stats=RPAL.GetStatistics()
    amount=math.floor(tonumber(amount) or 0)
    if not stats or amount<=0 then return 0 end

    options=type(options)=="table" and options or {}
    if not options.fixed and RPAL.ModifyRenownByTalents then
        amount=math.floor(tonumber(RPAL.ModifyRenownByTalents(amount,source)) or amount)
    end
    if amount<=0 then return 0 end

    -- Catch-up bonuses are real-play acceleration, never fabricated history.
    -- Both are based on the same post-Talent base amount so simultaneous buffs
    -- add cleanly instead of compounding one another.
    local baseAmount=amount
    local catchupBonus=0
    local calibration=RPAL.GetLegacyCalibration and RPAL.GetLegacyCalibration()
    if not options.fixed and source~="legacy_calibration" and RPAL.IsSceneActive and RPAL.IsSceneActive()
        and calibration and calibration.completed and (tonumber(calibration.catchupSessionsRemaining) or 0)>0 then
        local percent=math.max(0,tonumber(calibration.catchupPercent) or 0)
        local exact=(baseAmount*percent/100)+(tonumber(calibration.catchupRemainder) or 0)
        local bonus=math.floor(exact)
        calibration.catchupRemainder=exact-bonus
        catchupBonus=catchupBonus+bonus
    end

    local curveCatchup=stats.renownCurveCatchup
    if not options.fixed and source~="legacy_calibration" and RPAL.IsSceneActive and RPAL.IsSceneActive()
        and type(curveCatchup)=="table" and tonumber(curveCatchup.version)==RPAL.RENOWN_CURVE_VERSION
        and (tonumber(curveCatchup.sessionsRemaining) or 0)>0 then
        local percent=math.max(0,tonumber(curveCatchup.percent) or RPAL.RENOWN_CURVE_CATCHUP_PERCENT)
        local exact=(baseAmount*percent/100)+(tonumber(curveCatchup.remainder) or 0)
        local bonus=math.floor(exact)
        curveCatchup.remainder=exact-bonus
        catchupBonus=catchupBonus+bonus
        local activeScene=RPAL.GetActiveScene and RPAL.GetActiveScene()
        if activeScene then
            activeScene.renownCurveCatchupApplied=true
            activeScene.renownCurveCatchupBonus=(tonumber(activeScene.renownCurveCatchupBonus) or 0)+bonus
        end
    end
    amount=baseAmount+catchupBonus

    local oldLevel=select(1,RPAL.GetRenownLevelData(stats.score or 0))
    stats.score=(tonumber(stats.score) or 0)+amount
    if RPAL.AddCrests and not options.skipTokens then
        local baseCrests=amount*(RPAL.STONES_PER_RENOWN or RPAL.CRESTS_PER_RENOWN or 3)
        local talentCrests=(not options.fixed and RPAL.GetTalentExtraCrests) and RPAL.GetTalentExtraCrests(amount,source) or 0
        RPAL.AddCrests(baseCrests+math.max(0,math.floor(tonumber(talentCrests) or 0)))
    end
    local newLevel=select(1,RPAL.GetRenownLevelData(stats.score or 0))

    if newLevel>oldLevel then
        local rewards=RPAL.GrantLevelRewards and RPAL.GrantLevelRewards(oldLevel,newLevel) or {}
        if not options.suppressLevelUp then
            local function PresentLevelUp()
                if RPAL.OnRenownLevelUp then RPAL.OnRenownLevelUp(newLevel,oldLevel,source,rewards) end
            end
            local delay=math.max(0,tonumber(options.levelUpDelay) or 0)
            if delay>0 and C_Timer and C_Timer.After then C_Timer.After(delay,PresentLevelUp) else PresentLevelUp() end
        end
    end
    return amount
end


function RPAL.AddFixedRenown(amount,source)
    return RPAL.AddRenown(amount,source,{fixed=true})
end

function RPAL.GetStatistics()
    if not RPAL.char then return nil end
    if RPAL.SyncTRPProfileScope and not RPAL._syncingTRPProfileScope then
        RPAL._syncingTRPProfileScope=true
        RPAL.SyncTRPProfileScope(false)
        RPAL._syncingTRPProfileScope=false
    end
    RPAL.char.statistics=RPAL.char.statistics or {
        version=3,score=0,totalEvents=0,counts={},recent={},scoreThrottle={},
        metPlayers={},charactersMet=0,progressionCurveVersion=RPAL.RENOWN_CURVE_VERSION or 4
    }
    local s=RPAL.char.statistics
    s.version=tonumber(s.version) or 1
    s.score=tonumber(s.score) or 0
    s.totalEvents=tonumber(s.totalEvents) or 0
    s.counts=s.counts or {}
    s.recent=s.recent or {}
    s.scoreThrottle=s.scoreThrottle or {}
    s.metPlayers=s.metPlayers or {}
    s.charactersMet=tonumber(s.charactersMet) or 0
    if s.version<3 then s.version=3 end
    if RPAL.MigrateRenownCurve then RPAL.MigrateRenownCurve(s) end
    return s
end

-- Daily roleplay reward ------------------------------------------------------
-- Awarded once per active TRP profile per local calendar day after a strong,
-- direct IC interaction with another IC player.  Open Beta converts the old
-- Token bonus into Season XP: +5 Renown and +25 SXP while a Season is
-- active.  The fixed Renown grant does not double-dip the 5 Renown -> 1 SXP
-- conversion; its SXP portion is granted explicitly below.
RPAL.DAILY_RP_RENOWN=5
RPAL.DAILY_RP_SXP=25
RPAL.DAILY_RP_TOKENS=0 -- legacy compatibility field; Daily RP no longer grants Tokens.

local function DailyRPDayKey()
    if date then return date("%Y-%m-%d") end
    return tostring(math.floor((time() or 0)/86400))
end

function RPAL.TryGrantDailyRPReward(actor,reason)
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return false,"lite_kit" end
    if not RPAL.IsTRPInCharacter or not RPAL.IsTRPInCharacter() then return false,"not_ic" end
    if not RPAL.IsOtherTRPInCharacter or not RPAL.IsOtherTRPInCharacter(actor) then return false,"other_not_ic" end

    actor=tostring(actor or ""):match("^([^%-]+)") or tostring(actor or "")
    local own=tostring(UnitName and UnitName("player") or ""):match("^([^%-]+)") or ""
    if actor=="" or (own~="" and actor:lower()==own:lower()) then return false,"no_partner" end

    local scope=RPAL.GetCurrentTRPProfileScope and RPAL.GetCurrentTRPProfileScope() or RPAL.char
    if type(scope)~="table" then return false,"no_scope" end
    scope.dailyRPReward=type(scope.dailyRPReward)=="table" and scope.dailyRPReward or {}
    local reward=scope.dailyRPReward
    local day=DailyRPDayKey()
    if reward.dayKey==day then return false,"already_claimed" end

    -- Mark first to make the grant re-entry safe if level-up hooks trigger UI
    -- or SavedVariable synchronization during the reward transaction.
    reward.dayKey=day
    reward.claimedAt=time()
    reward.partner=actor
    reward.reason=tostring(reason or "direct_roleplay")

    local renown=RPAL.AddRenown and RPAL.AddRenown(RPAL.DAILY_RP_RENOWN,"daily_rp_reward",{fixed=true,skipTokens=true,skipSeasonXP=true}) or 0
    if renown<=0 then
        reward.dayKey=nil;reward.claimedAt=nil;reward.partner=nil;reward.reason=nil
        return false,"renown_unavailable"
    end
    local sxp=0
    if RPAL.AddSeasonXP and not (RPAL.IsLiteKit and RPAL.IsLiteKit()) then
        local ok=RPAL.AddSeasonXP(RPAL.DAILY_RP_SXP or 25,"daily_rp_reward",true)
        if ok then sxp=RPAL.DAILY_RP_SXP or 25 end
    end
    if RPAL.ShowDailyRPRewardToast then RPAL.ShowDailyRPRewardToast(RPAL.DAILY_RP_RENOWN,sxp) end
    if RPAL.OnStatisticsChanged then RPAL.OnStatisticsChanged("daily_rp_reward",true,"daily") end
    return true,"granted"
end

RPAL.SCORE_COOLDOWN_SECONDS=30
RPAL.SCORE_MAX_PER_PLAYER_HOUR=5

local function StatisticActorKey(actor)
    return tostring(actor or ""):lower()
end

local function PruneActorScoreHistory(entry,now)
    entry.history=entry.history or {}
    local cutoff=now-3600
    local clean={}
    for _,timestamp in ipairs(entry.history) do
        timestamp=tonumber(timestamp)
        if timestamp and timestamp>cutoff then
            clean[#clean+1]=timestamp
        end
    end
    entry.history=clean
end

local function CanTrackSocialActor(actor)
    local localStatus
    if RPAL.GetTRPCharacterStatus then localStatus=select(1,RPAL.GetTRPCharacterStatus())
    elseif RPAL.IsTRPInCharacter then localStatus=RPAL.IsTRPInCharacter() and "IC" or "UNKNOWN"
    else localStatus="UNKNOWN" end
    if localStatus=="UNKNOWN" or localStatus==nil then return false,"status_unknown" end
    if localStatus~="IC" then return false,"not_ic" end

    local remoteStatus
    if RPAL.GetOtherTRPCharacterStatus then remoteStatus=select(1,RPAL.GetOtherTRPCharacterStatus(actor))
    elseif RPAL.IsOtherTRPInCharacter then remoteStatus=RPAL.IsOtherTRPInCharacter(actor) and "IC" or "UNKNOWN"
    else remoteStatus="UNKNOWN" end
    if remoteStatus=="UNKNOWN" or remoteStatus==nil then
        if RPAL.RequestRemoteTRPStatus then RPAL.RequestRemoteTRPStatus(actor,false) end
        return false,"other_status_unknown"
    end
    if remoteStatus~="IC" then return false,"other_not_ic" end
    return true,"ic"
end
RPAL.CanTrackSocialActor=CanTrackSocialActor

function RPAL.CanScoreStatistic(actor)
    local trackable,trackReason=CanTrackSocialActor(actor)
    if not trackable then return false,trackReason end

    local stats=RPAL.GetStatistics()
    if not stats then return false,"no_stats" end

    local actorKey=StatisticActorKey(actor)
    if actorKey=="" then return false,"no_actor" end

    local now=time()
    local entry=stats.scoreThrottle[actorKey] or {lastScoreAt=0,history={}}
    stats.scoreThrottle[actorKey]=entry
    PruneActorScoreHistory(entry,now)

    local last=tonumber(entry.lastScoreAt) or 0
    if (now-last)<RPAL.SCORE_COOLDOWN_SECONDS then
        return false,"cooldown"
    end

    if #entry.history>=RPAL.SCORE_MAX_PER_PLAYER_HOUR then
        return false,"hourly_cap"
    end

    return true,"ok",entry,now
end

function RPAL.RecordStatistic(key,actor)
    local def=RPAL.STAT_DEFINITIONS[key]
    local stats=RPAL.GetStatistics()
    if not def or not stats then return false end

    -- Tracking and Renown throttling are deliberately separate. Every valid
    -- targeted IC emote belongs in the social history/stat counters; only the
    -- Renown payout is limited to one score per actor every 30 seconds / five
    -- per hour. Previously the anti-farm cooldown silently erased interactions.
    local trackable,trackReason=CanTrackSocialActor(actor)
    if not trackable then
        stats.lastRejected={key=key,actor=tostring(actor or ""),reason=trackReason,at=time()}
        if RPAL.OnStatisticsChanged then RPAL.OnStatisticsChanged(key,false,trackReason) end
        return false,trackReason
    end

    stats.counts[key]=(tonumber(stats.counts[key]) or 0)+1
    stats.totalEvents=(tonumber(stats.totalEvents) or 0)+1

    local rewarded,reason,entry,now=RPAL.CanScoreStatistic(actor)
    if rewarded then
        RPAL.AddRenown(tonumber(def.weight) or 1,"interaction:"..tostring(key))
        if RPAL.OnTalentStatisticScored then RPAL.OnTalentStatisticScored(key,actor) end
        if entry then
            now=now or time()
            entry.lastScoreAt=now
            entry.history=entry.history or {}
            entry.history[#entry.history+1]=now
        end
    else
        stats.lastSuppressedReward={key=key,actor=tostring(actor or ""),reason=reason,at=time()}
    end

    table.insert(stats.recent,1,{
        key=key,actor=tostring(actor or ""),at=time(),
        rewarded=rewarded and true or false,rewardReason=tostring(reason or trackReason or "tracked"),
    })
    while #stats.recent>25 do table.remove(stats.recent) end

    if RPAL.OnStatisticsChanged then
        RPAL.OnStatisticsChanged(key,true,rewarded and reason or ("tracked_"..tostring(reason or "no_reward")))
    end
    if RPAL.TryGrantDailyRPReward then RPAL.TryGrantDailyRPReward(actor,"social_interaction:"..tostring(key)) end
    return true,rewarded and reason or ("tracked_"..tostring(reason or "no_reward"))
end

local STAT_MESSAGE_MATCHERS={
    wave={"waves","wave"},
    pet={"pets","petting","pet"},
    peer={"peers","peer"},
    eye={"eyes you up and down","eyes","eying","eyeing"},
    smirk={"smirks","smirk"},
    snicker={"snickers","snicker"},
    cry={"cries","crying","cry"},
    frown={"frowns","frown"},
    smile={"smiles","smile"},
    dance={"dances","dancing","dance"},
    rude={"rude gesture","rude"},
    cheer={"cheers","cheer"},
    chicken={"flaps around like a chicken","struts around like a chicken","clucks at","clucks","chicken"},
    nod={"nods","nod"},
    no={"shakes your head","shakes their head","shakes his head","shakes her head","disagrees"},
    shy={"shies away","acts shy","bashful","shy"},
    hug={"hugs","hugging","hug"},
    flirt={"flirts","flirting","flirt"},
    gasp={"gasps","gasping","gasp"},
    bye={"waves goodbye","bids you farewell","says goodbye","goodbye"},
    yawn={"yawns","yawning","yawn"},
    glance={"glances","glance"},
    hiss={"hisses","hissing","hiss"},
    point={"points","pointing","point"},
    sniff={"sniffs","sniffing","sniff"},
}

local function RPALShortName(name)
    name=tostring(name or "")
    return name:match("^([^%-]+)") or name
end


local function NormalizeMetKey(guid,name)
    if guid and guid~="" then return tostring(guid) end
    return "name:"..tostring(name or ""):lower()
end

function RPAL.RecordCharacterMet(guid,name,initiatedByPlayer)
    local stats=RPAL.GetStatistics()
    if not stats then return false,"no_stats" end

    local eligible,eligibilityReason=CanTrackSocialActor(name)
    if not eligible then return false,eligibilityReason end

    local key=NormalizeMetKey(guid,name)
    if key=="name:" then return false,"no_actor" end
    if stats.metPlayers[key] then return false,"already_met" end

    stats.metPlayers[key]={
        name=tostring(name or ""),
        firstMetAt=time(),
        initiatedByPlayer=initiatedByPlayer and true or false,
    }
    stats.charactersMet=(tonumber(stats.charactersMet) or 0)+1
    local awardedRenown=RPAL.AddRenown(RPAL.CHARACTERS_MET_SCORE or 7,"character_met")

    table.insert(stats.recent,1,{
        key="characters_met",
        actor=tostring(name or ""),
        at=time(),
        points=awardedRenown or (RPAL.CHARACTERS_MET_SCORE or 7),
        initiatedByPlayer=initiatedByPlayer and true or false,
    })
    while #stats.recent>25 do table.remove(stats.recent) end

    if RPAL.OnStatisticsChanged then
        RPAL.OnStatisticsChanged("characters_met",true,"unique")
    end
    if RPAL.TryGrantDailyRPReward then RPAL.TryGrantDailyRPReward(name,"character_met") end
    return true,"unique"
end

local function IncomingSenderGUID(...)
    -- CHAT_MSG_* argument positions have changed between WoW versions. Do not
    -- hard-code a select() slot; the sender GUID is the only Player-* token.
    for index=1,select("#",...) do
        local value=select(index,...)
        if type(value)=="string" and value:find("^Player%-") then return value end
    end
    return nil
end

local function EventArgsMentionCharacter(name,...)
    local wanted=RPALShortName(name):lower()
    if wanted=="" then return false end
    for index=1,select("#",...) do
        local value=select(index,...)
        if type(value)=="string" and not value:find("^Player%-") then
            local short=RPALShortName(value):lower()
            if short==wanted then return true end
        end
    end
    return false
end

local function TryOutgoingMeet(message,sender,...)
    local ownName=RPALShortName(UnitName("player"))
    local senderShort=RPALShortName(sender)
    if senderShort=="" or ownName=="" or senderShort:lower()~=ownName:lower() then return false end
    if not UnitExists("target") or not UnitIsPlayer("target") or UnitIsUnit("target","player") then return false end

    local targetName=RPALShortName(UnitName("target"))
    if targetName=="" then return false end
    local lower=tostring(message or ""):lower()
    if not lower:find(targetName:lower(),1,true) and not EventArgsMentionCharacter(targetName,...) then return false end
    return RPAL.RecordCharacterMet(UnitGUID("target"),targetName,true)
end

local function FindStatisticKey(lower)
    for _,key in ipairs(RPAL.STAT_ORDER) do
        for _,needle in ipairs(STAT_MESSAGE_MATCHERS[key] or {}) do
            if lower:find(needle,1,true) then return key end
        end
    end
end

local function CommitTargetedSocialEmote(message,senderShort,guid,key)
    if RPAL.HandleTalentTargetedEmote then RPAL.HandleTalentTargetedEmote(message,senderShort) end
    RPAL.RecordCharacterMet(guid,senderShort,false)
    if key then return RPAL.RecordStatistic(key,senderShort) end
    return true,"targeted_contact"
end

local function QueueDeferredSocialEmote(message,senderFull,senderShort,guid,key)
    if not C_Timer or not C_Timer.After then return false end
    RPAL._pendingSocialEmoteCount=tonumber(RPAL._pendingSocialEmoteCount) or 0
    if RPAL._pendingSocialEmoteCount>=64 then return false end
    RPAL._pendingSocialEmoteCount=RPAL._pendingSocialEmoteCount+1
    local started=(GetTime and GetTime()) or 0
    local attempts=0
    local finished=false
    local function Finish()
        if finished then return end
        finished=true
        RPAL._pendingSocialEmoteCount=math.max(0,(tonumber(RPAL._pendingSocialEmoteCount) or 1)-1)
    end
    local function Retry()
        if finished then return end
        attempts=attempts+1
        local eligible,reason=CanTrackSocialActor(senderShort)
        if eligible then
            CommitTargetedSocialEmote(message,senderShort,guid,key)
            Finish()
            return
        end
        if reason~="status_unknown" and reason~="other_status_unknown" then Finish();return end
        if RPAL.RequestRemoteTRPStatus and reason=="other_status_unknown" then RPAL.RequestRemoteTRPStatus(senderFull,false) end
        local now=(GetTime and GetTime()) or started
        if attempts>=14 or (now-started)>6 then Finish();return end
        C_Timer.After(0.40,Retry)
    end
    if RPAL.RequestRemoteTRPStatus then RPAL.RequestRemoteTRPStatus(senderFull,false) end
    C_Timer.After(0.25,Retry)
    return true
end

function RPAL.HandleTextEmote(message,sender,...)
    if not RPAL.char or type(message)~="string" then return false end

    local senderFull=tostring(sender or "")
    local senderShort=RPALShortName(senderFull)
    local ownName=RPALShortName(UnitName("player"))
    if senderShort~="" and ownName~="" and senderShort:lower()==ownName:lower() then
        -- Own emotes never increment received counters. Target matching uses
        -- both the rendered text and the event's target argument so reload/API
        -- argument layout changes do not make mutual emotes disappear.
        if RPAL.HandleTalentOutgoingEmote and UnitExists("target") and UnitIsPlayer("target") and not UnitIsUnit("target","player") then
            local targetName=RPALShortName(UnitName("target"))
            local lower=tostring(message or ""):lower()
            if targetName~="" and (lower:find(targetName:lower(),1,true) or EventArgsMentionCharacter(targetName,...)) then
                RPAL.HandleTalentOutgoingEmote(message,targetName)
            end
        end
        TryOutgoingMeet(message,sender,...)
        return false
    end

    local lower=message:lower()
    local firstName=tostring((RPAL.char.profile or {}).firstName or ""):lower()
    local unitName=tostring(ownName or ""):lower()
    local targeted=EventArgsMentionCharacter(ownName,...)
        or lower:find("%f[%a]you%f[%A]")~=nil
        or (unitName~="" and lower:find(unitName,1,true)~=nil)
        or (firstName~="" and #firstName>=3 and lower:find(firstName,1,true)~=nil)
    if not targeted then return false end

    RPAL._lastStatisticMessage=RPAL._lastStatisticMessage or {}
    local signature=lower.."|"..tostring(senderShort):lower()
    local now=GetTime and GetTime() or 0
    if RPAL._lastStatisticMessage.signature==signature and (now-(RPAL._lastStatisticMessage.at or 0))<0.25 then return false end
    RPAL._lastStatisticMessage.signature=signature
    RPAL._lastStatisticMessage.at=now

    local key=FindStatisticKey(lower)
    local guid=IncomingSenderGUID(...)
    local eligible,reason=CanTrackSocialActor(senderShort)
    if eligible then return CommitTargetedSocialEmote(message,senderShort,guid,key) end

    -- On a fresh login/reload TRP/LibMSP can need a few frames to repopulate
    -- FC for players who were already IC. Preserve the interaction and replay
    -- it once status arrives instead of losing the emote forever.
    if reason=="status_unknown" or reason=="other_status_unknown" then
        QueueDeferredSocialEmote(message,senderFull,senderShort,guid,key)
        return false,"deferred_"..tostring(reason)
    end
    return false,reason
end


-- ============================================================
-- RPAL player-to-player social snapshot
-- ============================================================

function RPAL.GetSortedStatisticKeys(stats,limit)
    local keys={}
    stats=stats or RPAL.GetStatistics() or {}
    local counts=stats.counts or {}
    for _,key in ipairs(RPAL.STAT_ORDER or {}) do
        keys[#keys+1]=key
    end
    table.sort(keys,function(a,b)
        local ca=tonumber(counts[a]) or 0
        local cb=tonumber(counts[b]) or 0
        if ca~=cb then return ca>cb end
        local da=RPAL.STAT_DEFINITIONS[a] or {}
        local db=RPAL.STAT_DEFINITIONS[b] or {}
        return tostring(da.command or a)<tostring(db.command or b)
    end)
    if limit then
        limit=math.max(0,math.floor(tonumber(limit) or #keys))
        while #keys>limit do table.remove(keys) end
    end
    return keys
end

function RPAL.GetCompletedAchievementSnapshot()
    local completed={}
    local total=0

    -- Inspect shares the Character book for this character.
    local library=RPAL.char and RPAL.char.library or {}
    local progress=RPAL.char and RPAL.char.progress or {}
    local favourites=RPAL.char and RPAL.char.favourites or {}
    local favouriteSet={}

    for _,id in ipairs(favourites) do favouriteSet[id]=true end

    for _,category in ipairs(library or {}) do
        for _,badge in ipairs(category.badges or {}) do
            total=total+1
            local p=progress[badge.id]
            if p and p.completed then
                completed[#completed+1]={
                    id=badge.id,
                    name=badge.name or "Unnamed Achievement",
                    category=category.name or "Achievement",
                    favourite=favouriteSet[badge.id] and true or false,
                    icon=badge.icon or "",
                    description=badge.description or "",
                }
            end
        end
    end

    table.sort(completed,function(a,b)
        if a.favourite~=b.favourite then return a.favourite end
        if a.category~=b.category then return a.category<b.category end
        return a.name<b.name
    end)

    return completed,total
end

function RPAL.GetPublicStatusFlags()
    local hungry,dehydrated=false,false
    if RPAL.GetSurvivalFlags then hungry,dehydrated=RPAL.GetSurvivalFlags() end

    local injured,healing=false,false
    local injurySnapshot=RPAL.GetPublicInjurySnapshot and RPAL.GetPublicInjurySnapshot()
    for _,item in pairs((injurySnapshot and injurySnapshot.items) or {}) do
        if item.category=="healing" then
            healing=true
        elseif item.category=="injury" or item.category==nil then
            injured=true
        end
        -- Permanent Changes are persistent character state, not an active
        -- injury/debuff, so Prosthetic/Amputated never set INJURED.
        if injured and healing then break end
    end
    return {hungry=hungry and true or false,dehydrated=dehydrated and true or false,injured=injured,healing=healing}
end

function RPAL.GetPublicSocialSnapshot()
    local p=RPAL.char and RPAL.char.profile or {}
    local stats=RPAL.GetStatistics() or {score=0,totalEvents=0,counts={}}
    local completed,total=RPAL.GetCompletedAchievementSnapshot()

    -- Preserve TRP's exact selected color when one exists. Text-only TRP eye
    -- fields are retained for UI-side named-color resolution. Older imports
    -- may only have the value in Additional Fields, so recover it there.
    local trpEyeHex=tostring(p.trpEyeHex or "")
    local trpEyeText=tostring(p.trpEyeText or "")
    local additionalFields={}
    if p.useTRPInformation then
        for _,field in ipairs(p.trpAdditionalFields or {}) do
            local label=tostring(field.label or "")
            local value=tostring(field.value or "")
            local normalized=label:lower():gsub("[%s%p%c]","")
            local eyeField=normalized=="eyecolor" or normalized=="eyecolour" or normalized=="eyes"
            if eyeField then
                if trpEyeText=="" and value~="" then trpEyeText=value end
            elseif label~="" and value~="" then
                additionalFields[#additionalFields+1]={
                    id=tostring(field.id or ""),
                    label=label,
                    value=value,
                }
            end
        end
    end

    local eyeColorText=""
    local function ColorHex(color)
        if type(color)~="table" then return "" end
        local r=math.max(0,math.min(255,math.floor((tonumber(color.r) or 0)*255+0.5)))
        local g=math.max(0,math.min(255,math.floor((tonumber(color.g) or 0)*255+0.5)))
        local b=math.max(0,math.min(255,math.floor((tonumber(color.b) or 0)*255+0.5)))
        return string.format("#%02X%02X%02X",r,g,b)
    end
    local selectedTRPHex=trpEyeHex:match("#?(%x%x%x%x%x%x)")
    if p.useTRPInformation and selectedTRPHex then
        eyeColorText="#"..selectedTRPHex:upper()
    elseif p.useTRPInformation and trpEyeText~="" then
        eyeColorText=trpEyeText
    elseif p.eyeMode=="blind" then
        eyeColorText="Blind"
    elseif p.eyeMode=="heterochromia" then
        local left,right=ColorHex(p.leftEyeColor),ColorHex(p.rightEyeColor)
        if left~="" or right~="" then eyeColorText=left..(left~="" and right~="" and " / " or "")..right end
    else
        eyeColorText=ColorHex(p.eyeColor)
    end

    -- Campaigns pinned from Sessions → Campaigns share the existing public
    -- memory shelf with pinned Sessions. They are exported as read-only memory
    -- cards so older RPAL inspect clients can still render them safely.
    local publicPinnedMoments={}
    for _,item in ipairs(RPAL.GetPinnedMoments and RPAL.GetPinnedMoments() or {}) do publicPinnedMoments[#publicPinnedMoments+1]=item end
    for _,campaign in ipairs(RPAL.GetPinnedDMCampaigns and RPAL.GetPinnedDMCampaigns() or {}) do
        if #publicPinnedMoments>=24 then break end
        publicPinnedMoments[#publicPinnedMoments+1]={
            title="Campaign: "..tostring(campaign.name or "Completed Campaign"),zone="CAMPAIGN",at=campaign.concludedAt or 0,
            duration=campaign.duration or 0,interactions=0,renownGained=campaign.renownEarned or 0,
            text="Completed Campaign"..((campaign.hostTRP and campaign.hostTRP~="") and (" · Hosted by "..campaign.hostTRP) or ""),
            locations={},peopleDetails={},readOnly=true,campaignMemory=true,
        }
    end

    -- Keep the public snapshot detached from SavedVariables.  TRP fields are
    -- public profile information, but only while the TRP information layout is
    -- active; switching back to RPAL's default layout must hide the retained
    -- import cache again.
    return {
        displayName=RPAL.DisplayName and RPAL.DisplayName() or RPAL.FullName(),
        title=p.title or "",
        race=RPAL.VisibleRace and RPAL.VisibleRace() or (p.race or ""),
        className=p.className or "",
        guild=RPAL.GuildDisplay and RPAL.GuildDisplay() or "",
        score=tonumber(stats.score) or 0,
        totalEvents=tonumber(stats.totalEvents) or 0,
        charactersMet=tonumber(stats.charactersMet) or 0,
        campaignsJoined=tonumber(stats.campaignsJoined) or 0,
        counts=stats.counts or {},
        achievements=completed,
        achievementTotal=total,
        pinnedMoments=publicPinnedMoments,
        pinnedCampaigns=RPAL.GetPinnedDMCampaigns and RPAL.GetPinnedDMCampaigns() or {},
        pinnedStories=RPAL.GetPinnedStoryArcs and RPAL.GetPinnedStoryArcs() or {},
        cosmetics=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or {},
        glances=p.glances or {},
        statuses=RPAL.GetPublicStatusFlags(),
        information={
            age=p.age or "",
            height=p.height or "",
            bodyShape=p.useTRPInformation and (p.bodyShape or "") or "",
            weight=(not p.useTRPInformation) and (p.weight or "") or "",
            hairColor=p.hairColor or "",
            eyeColor=eyeColorText,
            pronouns=p.pronouns or "",
            relationship=(p.relationship=="Don't Show") and "" or (p.relationship or ""),
            birthplace=p.birthplace or "",
            residence=p.residence or "",
            commonlyFound=p.commonlyFound or "",
            additionalFields=additionalFields,
        },
    }
end

function RPAL.GetSelfPublicPreviewSnapshot()
    local snapshot=RPAL.GetPublicSocialSnapshot and RPAL.GetPublicSocialSnapshot() or {}
    snapshot.completedAchievements=RPAL.GetCompletedAchievementSnapshot and RPAL.GetCompletedAchievementSnapshot() or {}
    return snapshot
end


function RPAL.ExportCharacter()
    local p=RPAL.char.profile
    local lines={"RPALCHAR5"}

    local textKeys={
        "title","firstName","surname","pronouns","guildRank","guildName",
        "className","race","age","birthplace","residence","height","bodyShape","weight",
        "hairColor","relationship","commonlyFound","eyeMode","trpEyeText","trpEyeHex",
    }

    for _,key in ipairs(textKeys) do
        lines[#lines+1]=table.concat({
            "M",ByteHexEncode(key),ByteHexEncode(p[key] or "")
        },";")
    end

    local boolKeys={
        "easyMode","worgen","kulTiran","maghar","au","mu","useTRPInformation"
    }
    for _,key in ipairs(boolKeys) do
        lines[#lines+1]=table.concat({
            "F",ByteHexEncode(key),p[key] and "1" or "0"
        },";")
    end

    local colorKeys={"eyeColor","leftEyeColor","rightEyeColor","furColor","classColor"}
    for _,key in ipairs(colorKeys) do
        local color=p[key]
        if color then
            lines[#lines+1]=table.concat({
                "C",ByteHexEncode(key),
                ByteHexEncode(tostring(color.r or 1)),
                ByteHexEncode(tostring(color.g or 1)),
                ByteHexEncode(tostring(color.b or 1)),
            },";")
        end
    end

    for _,race in ipairs(p.customRaces or {}) do
        lines[#lines+1]="R;"..ByteHexEncode(race)
    end

    for _,field in ipairs(p.trpAdditionalFields or {}) do
        lines[#lines+1]=table.concat({
            "X",
            ByteHexEncode(field.id or ""),
            ByteHexEncode(field.label or ""),
            ByteHexEncode(field.value or ""),
        },";")
    end

    for index=1,5 do
        local glance=p.glances and p.glances[index]
        if type(glance)=="table" then
            lines[#lines+1]=table.concat({
                "G",
                tostring(index),
                ByteHexEncode(glance.title or ""),
                ByteHexEncode(glance.description or ""),
                ByteHexEncode(glance.icon or ""),
            },";")
        end
    end

    for _,badgeID in ipairs(RPAL.char.favourites or {}) do
        lines[#lines+1]="V;"..ByteHexEncode(badgeID)
    end

    for badgeID,progress in pairs(RPAL.char.progress or {}) do
        lines[#lines+1]=table.concat({
            "P",
            ByteHexEncode(badgeID),
            progress.completed and "1" or "0",
            ByteHexEncode(tostring(progress.completedAt or "")),
            ByteHexEncode(progress.note or ""),
        },";")
    end

    local stats=RPAL.GetStatistics()
    if stats then
        lines[#lines+1]=table.concat({"S",ByteHexEncode("score"),ByteHexEncode(tostring(stats.score or 0))},";")
        lines[#lines+1]=table.concat({"S",ByteHexEncode("totalEvents"),ByteHexEncode(tostring(stats.totalEvents or 0))},";")
        lines[#lines+1]=table.concat({"S",ByteHexEncode("charactersMet"),ByteHexEncode(tostring(stats.charactersMet or 0))},";")
        for metKey,entry in pairs(stats.metPlayers or {}) do
            lines[#lines+1]=table.concat({
                "E",
                ByteHexEncode(metKey),
                ByteHexEncode((entry and entry.name) or ""),
                ByteHexEncode(tostring((entry and entry.firstMetAt) or "")),
                (entry and entry.initiatedByPlayer) and "1" or "0"
            },";")
        end
        for _,key in ipairs(RPAL.STAT_ORDER or {}) do
            lines[#lines+1]=table.concat({"S",ByteHexEncode("count:"..key),ByteHexEncode(tostring((stats.counts or {})[key] or 0))},";")
        end
    end

    return table.concat(lines,"\n")
end


function RPAL.ImportCharacter(text)
    if type(text)~="string" then
        return false,"Invalid character data."
    end
    if #text>2097152 then
        return false,"Character data is too large."
    end

    local version
    if text:find("^RPALCHAR5") then
        version=5
    elseif text:find("^RPALCHAR4") then
        version=4
    elseif text:find("^RPALCHAR3") then
        version=3
    else
        return false,"Unsupported character data format. Re-export Character Data with the current addon version."
    end

    local p=DefaultProfile()
    local progress={}
    local importedGlances={}
    local importedFavourites={}
    local importedStatistics={
        version=3,score=0,totalEvents=0,counts={},recent={},scoreThrottle={},
        metPlayers={},charactersMet=0
    }
    p.customRaces={}
    p.trpAdditionalFields={}

    for line in text:gmatch("[^\r\n]+") do
        if line~="RPALCHAR5" and line~="RPALCHAR4" and line~="RPALCHAR3" then
            local f=SplitLiteral(line,";")
            local kind=f[1]

            if kind=="M" and #f==3 then
                if #f~=3 then return false,"Malformed character META record." end
                local key=ByteHexDecode(f[2])
                local value=ByteHexDecode(f[3])
                if not key or value==nil then return false,"Invalid character META encoding." end
                if p[key]~=nil then p[key]=value end

            elseif kind=="F" then
                if #f~=3 then return false,"Malformed character FLAG record." end
                local key=ByteHexDecode(f[2])
                if not key then return false,"Invalid character FLAG encoding." end
                if p[key]~=nil then p[key]=(f[3]=="1") end

            elseif kind=="C" then
                if #f~=5 then return false,"Malformed character COLOR record." end
                local key=ByteHexDecode(f[2])
                local r=tonumber(ByteHexDecode(f[3]) or "")
                local g=tonumber(ByteHexDecode(f[4]) or "")
                local b=tonumber(ByteHexDecode(f[5]) or "")
                if not key or not r or not g or not b then return false,"Invalid character COLOR data." end
                if p[key]~=nil then p[key]={r=r,g=g,b=b} end

            elseif kind=="R" then
                if #f~=2 then return false,"Malformed custom race record." end
                local race=ByteHexDecode(f[2])
                if race==nil then return false,"Invalid custom race encoding." end
                if race~="" then p.customRaces[#p.customRaces+1]=race end

            elseif kind=="X" and version>=4 then
                if #f~=4 then return false,"Malformed TRP Additional Field record." end
                local id=ByteHexDecode(f[2])
                local label=ByteHexDecode(f[3])
                local value=ByteHexDecode(f[4])
                if id==nil or label==nil or value==nil then
                    return false,"Invalid TRP Additional Field encoding."
                end
                p.trpAdditionalFields[#p.trpAdditionalFields+1]={
                    id=id,label=label,value=value
                }

            elseif kind=="G" then
                if #f~=5 then return false,"Malformed glance record." end
                local index=tonumber(f[2])
                local title=ByteHexDecode(f[3])
                local description=ByteHexDecode(f[4])
                local icon=ByteHexDecode(f[5])
                if not index or index<1 or index>5 or title==nil or description==nil or icon==nil then
                    return false,"Invalid glance record."
                end
                importedGlances[index]={title=title,description=description,icon=icon}

            elseif kind=="V" then
                if #f~=2 then return false,"Malformed favourite record." end
                local badgeID=ByteHexDecode(f[2])
                if not badgeID then return false,"Invalid favourite encoding." end
                importedFavourites[#importedFavourites+1]=badgeID

            elseif kind=="P" then
                if #f~=5 then return false,"Malformed progress record." end
                local badgeID=ByteHexDecode(f[2])
                local timestamp=ByteHexDecode(f[4])
                local note=ByteHexDecode(f[5])
                if not badgeID or timestamp==nil or note==nil then
                    return false,"Invalid progress encoding."
                end
                progress[badgeID]={
                    completed=(f[3]=="1"),
                    completedAt=tonumber(timestamp),
                    note=note,
                }

            elseif (kind=="E" or kind=="M") and version>=5 then
                if #f~=5 then return false,"Malformed met-player record." end
                local metKey=ByteHexDecode(f[2])
                local metName=ByteHexDecode(f[3])
                local metAt=tonumber(ByteHexDecode(f[4]) or "")
                if not metKey or metName==nil then return false,"Invalid met-player encoding." end
                importedStatistics.metPlayers[metKey]={
                    name=metName,
                    firstMetAt=metAt,
                    initiatedByPlayer=(f[5]=="1"),
                }

            elseif kind=="S" and version>=5 then
                if #f~=3 then return false,"Malformed statistics record." end
                local key=ByteHexDecode(f[2])
                local value=tonumber(ByteHexDecode(f[3]) or "")
                if not key or value==nil then return false,"Invalid statistics encoding." end
                if key=="score" then
                    importedStatistics.score=value
                elseif key=="totalEvents" then
                    importedStatistics.totalEvents=value
                elseif key=="charactersMet" then
                    importedStatistics.charactersMet=value
                else
                    local statKey=key:match("^count:(.+)$")
                    if statKey then importedStatistics.counts[statKey]=value end
                end
            else
                return false,"Unknown character record."
            end
        end
    end

    p=EnsureProfile(p)
    p.glances={}
    for index=1,5 do
        if importedGlances[index] then p.glances[index]=importedGlances[index] end
    end

    -- De-duplicate custom races.
    do
        local clean,seen={},{}
        for _,race in ipairs(p.customRaces or {}) do
            local key=tostring(race):lower()
            if race~="" and not seen[key] then
                seen[key]=true
                clean[#clean+1]=race
            end
        end
        p.customRaces=clean
    end

    -- Keep favourites only when the achievement still exists in this character book.
    do
        local valid={}
        for _,category in ipairs(RPAL.char.library or {}) do
            for _,badge in ipairs(category.badges or {}) do
                valid[badge.id]=true
            end
        end

        local clean={}
        for _,badgeID in ipairs(importedFavourites) do
            if valid[badgeID] then clean[#clean+1]=badgeID end
        end
        importedFavourites=clean
    end

    RPAL.SetTRPProfileScopedField("profile",p)
    RPAL.SetTRPProfileScopedField("favourites",importedFavourites)
    RPAL.SetTRPProfileScopedField("progress",progress)
    if version>=5 then
        RPAL.SetTRPProfileScopedField("statistics",importedStatistics)
    end

    if RPAL.Refresh then RPAL.Refresh() end
    return true
end
