local ADDON_NAME, RPAL = ...

-- ============================================================
-- Built-in achievement catalogue + automatic achievement engine
-- ============================================================
-- The ledger itself stays user-editable.  Built-ins are merged once per
-- catalogue version so upgrading adds new RPAL achievements without replacing
-- custom categories, names, notes or manually-created achievements.

local CATALOG_VERSION=tonumber(RPAL.ACHIEVEMENT_CATALOG_VERSION) or 1
local ACCOUNT_CATALOG_VERSION=tonumber(RPAL.ACCOUNT_ACHIEVEMENT_CATALOG_VERSION) or CATALOG_VERSION
local previousCategoryNames={
    starter={"Starter","Getting Started"},
    social={"Social"},
    working={"Working","Sessions"},
    campaigning={"Campaigning"},
    health={"Health","Health & Recovery"},
    guild={"Guild","Guild & Community"},
    pvp={"PvP","Combat"},
    ["good-deeds"]={"Good Deeds"},
    ["bad-deeds"]={"Bad Deeds"},
    chronicle={"Chronicle"},progression={"Renown"},planning={"Planning"},minigames={"Mini Games"},
}

local function Copy(value)
    return RPAL.Copy and RPAL.Copy(value) or value
end

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

local function FindBadge(category,id)
    for _,badge in ipairs(category and category.badges or {}) do if badge.id==id then return badge end end
end

local function WasDefaultCategoryName(id,name)
    name=tostring(name or "")
    for _,old in ipairs(previousCategoryNames[id] or {}) do if name==old then return true end end
    return name==""
end

function RPAL.MergeBuiltInAchievements(library,defaults)
    if type(library)~="table" then return 0,0 end
    defaults=type(defaults)=="table" and defaults or RPAL.DEFAULT_LIBRARY or {}
    local categoriesAdded,badgesAdded=0,0
    for _,builtin in ipairs(defaults) do
        local category=FindCategory(library,builtin.id)
        if not category then
            table.insert(library,Copy(builtin));categoriesAdded=categoriesAdded+1
        else
            category.badges=type(category.badges)=="table" and category.badges or {}
            if WasDefaultCategoryName(category.id,category.name) then category.name=builtin.name end
            for _,badge in ipairs(builtin.badges or {}) do
                if not FindBadge(category,badge.id) then
                    table.insert(category.badges,Copy(badge));badgesAdded=badgesAdded+1
                end
            end
        end
    end
    return categoriesAdded,badgesAdded
end

local function UpgradeContainer(container,libraryField,versionField,version,defaults)
    if type(container)~="table" then return 0,0 end
    libraryField=libraryField or "library";versionField=versionField or "achievementCatalogVersion"
    version=math.max(1,math.floor(tonumber(version) or CATALOG_VERSION))
    local library=container[libraryField]
    if type(library)~="table" then return 0,0 end
    if math.floor(tonumber(container[versionField]) or 0)>=version then return 0,0 end
    local c,b=RPAL.MergeBuiltInAchievements(library,defaults)
    container[versionField]=version
    return c,b
end

-- v2.14: the old Account book was seeded with a copy of the Character book.
-- Rebuild only those old built-in rows into the dedicated account catalogue;
-- custom categories/badges created by the player remain untouched.
local function RebuildLegacyAccountBook()
    if type(RPAL_DB)~="table" then return 0 end
    if math.floor(tonumber(RPAL_DB.accountAchievementCatalogVersion) or 0)>=ACCOUNT_CATALOG_VERSION then return 0 end
    local library=type(RPAL_DB.accountLibrary)=="table" and RPAL_DB.accountLibrary or {}
    RPAL_DB.accountLibrary=library

    local legacyCategoryIDs,legacyBadgeIDs={},{}
    for _,category in ipairs(RPAL.DEFAULT_LIBRARY or {}) do
        legacyCategoryIDs[category.id]=true
        for _,badge in ipairs(category.badges or {}) do legacyBadgeIDs[badge.id]=true end
    end

    local removed=0
    for ci=#library,1,-1 do
        local category=library[ci]
        if type(category)=="table" and legacyCategoryIDs[category.id] then
            local kept={}
            for _,badge in ipairs(type(category.badges)=="table" and category.badges or {}) do
                if not legacyBadgeIDs[badge.id] then kept[#kept+1]=badge else removed=removed+1 end
            end
            category.badges=kept
            if #kept==0 and WasDefaultCategoryName(category.id,category.name) then table.remove(library,ci) end
        end
    end

    -- Drop favourites that pointed to removed character-only built-ins.
    local valid={}
    for _,category in ipairs(library) do for _,badge in ipairs(category.badges or {}) do valid[badge.id]=true end end
    local favourites=type(RPAL_DB.accountFavourites)=="table" and RPAL_DB.accountFavourites or {}
    for i=#favourites,1,-1 do if legacyBadgeIDs[favourites[i]] and not valid[favourites[i]] then table.remove(favourites,i) end end
    RPAL_DB.accountFavourites=favourites
    return removed
end

function RPAL.UpgradeAchievementCatalog()
    local categories,badges=0,0
    local c,b
    if type(RPAL_DB)=="table" then
        if math.floor(tonumber(RPAL_DB.accountAchievementCatalogVersion) or 0)<ACCOUNT_CATALOG_VERSION then
            RebuildLegacyAccountBook()
            c,b=RPAL.MergeBuiltInAchievements(RPAL_DB.accountLibrary,RPAL.DEFAULT_ACCOUNT_LIBRARY or {})
            categories=categories+c;badges=badges+b
            RPAL_DB.accountAchievementCatalogVersion=ACCOUNT_CATALOG_VERSION
        end
    end
    if type(RPAL_CHAR_DB)=="table" then
        c,b=UpgradeContainer(RPAL_CHAR_DB,"library","achievementCatalogVersion",CATALOG_VERSION,RPAL.DEFAULT_LIBRARY);categories=categories+c;badges=badges+b
        for _,scope in pairs(type(RPAL_CHAR_DB.trpProfileScopes)=="table" and RPAL_CHAR_DB.trpProfileScopes or {}) do
            c,b=UpgradeContainer(scope,"library","achievementCatalogVersion",CATALOG_VERSION,RPAL.DEFAULT_LIBRARY);categories=categories+c;badges=badges+b
        end
    end
    return categories,badges
end

-- Wrap initialization/profile binding so new TRP identities and existing books
-- always receive the current catalogue exactly once.
do
    local original=RPAL.InitializeDB
    if type(original)=="function" and not RPAL._achievementWrappedInitialize then
        RPAL._achievementWrappedInitialize=true
        RPAL.InitializeDB=function(...)
            local results={original(...)}
            RPAL.UpgradeAchievementCatalog()
            return unpack(results)
        end
    end
end

do
    local original=RPAL.SyncTRPProfileScope
    if type(original)=="function" and not RPAL._achievementWrappedScopeSync then
        RPAL._achievementWrappedScopeSync=true
        RPAL.SyncTRPProfileScope=function(...)
            local results={original(...)}
            RPAL.UpgradeAchievementCatalog()
            return unpack(results)
        end
    end
end

-- ------------------------------------------------------------
-- Automatic achievement metrics
-- ------------------------------------------------------------
local function CountMap(t,predicate)
    local n=0
    for k,v in pairs(type(t)=="table" and t or {}) do if not predicate or predicate(k,v) then n=n+1 end end
    return n
end

local function Stats()
    return RPAL.GetStatistics and RPAL.GetStatistics() or (RPAL.char and RPAL.char.statistics) or {}
end

local function Scenes()
    if RPAL.GetScenes then return RPAL.GetScenes() or {} end
    local d=RPAL.GetRoleplayData and RPAL.GetRoleplayData()
    return d and d.scenes or {}
end

local function SceneParticipants(scene)
    if type(scene)~="table" then return 0 end
    if type(scene.activeParticipantNames)=="table" then return #scene.activeParticipantNames end
    local count=0
    for _,p in pairs(type(scene.participants)=="table" and scene.participants or {}) do
        if type(p)=="table" and (p.activeRP==true or p.met==true or (tonumber(p.interactions) or 0)>0) then count=count+1 end
    end
    return count
end

local function SceneInteractions(scene)
    local total=0
    for _,value in pairs(type(scene and scene.interactions)=="table" and scene.interactions or {}) do total=total+(tonumber(value) or 0) end
    return total
end

local function SceneDuration(scene)
    if type(scene)~="table" then return 0 end
    return math.max(0,tonumber(scene.duration) or tonumber(scene.activeSeconds) or 0)
end

local function SessionMetric(kind)
    local scenes=Scenes()
    if kind=="count" then return #scenes end
    local total,maxValue=0,0
    for _,scene in ipairs(scenes) do
        local value=0
        if kind=="duration" or kind=="maxDuration" then value=SceneDuration(scene)
        elseif kind=="participants" then value=SceneParticipants(scene)
        elseif kind=="interactions" then value=SceneInteractions(scene)
        elseif kind=="resumes" then value=math.max(0,tonumber(scene.resumeCount) or 0) end
        total=total+value;if value>maxValue then maxValue=value end
    end
    if kind=="maxDuration" or kind=="participants" then return maxValue end
    return total
end

local function RoleplayData()
    return RPAL.GetRoleplayData and RPAL.GetRoleplayData() or (RPAL.char and RPAL.char.roleplay) or {}
end
local function MomentCount() return #(RPAL.GetMoments and RPAL.GetMoments() or RoleplayData().moments or {}) end
local function PinnedMomentCount()
    if RPAL.GetPinnedMoments then return #(RPAL.GetPinnedMoments() or {}) end
    local n=0;for _,m in ipairs(RoleplayData().moments or {}) do if m.pinned then n=n+1 end end;return n
end
local function BondCount()
    if RPAL.GetBonds then return #(RPAL.GetBonds() or {}) end
    return CountMap(RoleplayData().bonds)
end
local function CompletedArcCount()
    local n=0;for _,arc in ipairs(RPAL.GetStoryArcs and RPAL.GetStoryArcs() or RoleplayData().arcs or {}) do if arc.completed then n=n+1 end end;return n
end

local function CampaignRecords()
    return RPAL.GetDMCampaignRecords and RPAL.GetDMCampaignRecords() or {}
end
local function CampaignMetric(kind)
    if kind=="joined" then return math.max(0,tonumber(Stats().campaignsJoined) or 0) end
    local records=CampaignRecords();if kind=="finished" then return #records end
    local total=0
    for _,item in ipairs(records) do
        if kind=="sessions" then total=total+math.max(0,tonumber(item.sessionCount) or 0)
        elseif kind=="saved" then total=total+math.max(0,tonumber(item.npcsSaved) or 0)
        elseif kind=="killed" then total=total+math.max(0,tonumber(item.npcsKilled) or 0) end
    end
    return total
end

local function RenownLevel() return RPAL.GetCurrentRenownLevel and select(1,RPAL.GetCurrentRenownLevel()) or 1 end
local function CosmeticCount()
    local collection=RPAL.GetCosmeticCollection and RPAL.GetCosmeticCollection() or {}
    local total=0
    for _,owned in pairs(type(collection.unlockedCosmetics)=="table" and collection.unlockedCosmetics or {}) do
        for _,isOwned in pairs(type(owned)=="table" and owned or {}) do if isOwned then total=total+1 end end
    end
    for _,isOwned in pairs(type(collection.unlockedThemes)=="table" and collection.unlockedThemes or {}) do if isOwned then total=total+1 end end
    return total
end
local function CalendarCount()
    if RPAL.GetCalendarEvents then return #(RPAL.GetCalendarEvents() or {}) end
    return #((RPAL.char and RPAL.char.calendar and RPAL.char.calendar.events) or {})
end
local function InjuryMetric(kind)
    local db=RPAL.EnsureInjuryDB and RPAL.EnsureInjuryDB() or (RPAL.char and RPAL.char.injuries) or {}
    local items=type(db.items)=="table" and db.items or {}
    if kind=="count" then return CountMap(items) end
    local n=0
    for _,item in pairs(items) do
        if kind=="healing" and item.category=="healing" then n=n+1
        elseif kind=="timer" and item.category=="healing" and tonumber(item.expiresAt) and tonumber(item.expiresAt)>time() then n=n+1 end
    end
    return n
end

local function HearthstoneMetric(kind)
    local HS=RPAL.Hearthstone
    if not HS or not HS.GetState then return 0 end
    local state=HS.GetState() or {}
    if kind=="packs" then return math.max(0,tonumber(state.packsOpened) or 0) end
    local history=type(state.history)=="table" and state.history or {}
    if kind=="matches" then return #history end
    local wins,lethal=0,0
    for _,item in ipairs(history) do
        if tostring(item.result or ""):lower()=="win" then
            wins=wins+1
            if tostring(item.reason or ""):lower()=="lethal" then lethal=lethal+1 end
        end
    end
    return kind=="lethal" and lethal or wins
end

-- ------------------------------------------------------------
-- Account-wide legacy snapshot
-- ------------------------------------------------------------
local function CopyTruthyKeys(source)
    local out={}
    for key,value in pairs(type(source)=="table" and source or {}) do if value then out[tostring(key)]=true end end
    return out
end

local function CountSessionsWithMinParticipants(minimum)
    local n=0
    for _,scene in ipairs(Scenes()) do if SceneParticipants(scene)>=minimum then n=n+1 end end
    return n
end

local SOCIAL_KEYS={"nod","smile","wave","snicker","frown","cheer","peer","smirk","bye","sniff","cry","dance","eye","flirt","gasp","glance","hiss","hug","no","pet"}
local function SocialVariety()
    local counts=type(Stats().counts)=="table" and Stats().counts or {}
    local n=0
    for _,key in ipairs(SOCIAL_KEYS) do if (tonumber(counts[key]) or 0)>0 then n=n+1 end end
    return n
end

function RPAL.CaptureAccountAchievementSnapshot()
    if type(RPAL_DB)~="table" or not RPAL.char then return nil end
    RPAL_DB.accountAchievementLedger=type(RPAL_DB.accountAchievementLedger)=="table" and RPAL_DB.accountAchievementLedger or {version=1,identities={}}
    local ledger=RPAL_DB.accountAchievementLedger
    ledger.version=1;ledger.identities=type(ledger.identities)=="table" and ledger.identities or {}
    local identity=(RPAL.GetCurrentCharacterIdentityKey and select(1,RPAL.GetCurrentCharacterIdentityKey())) or tostring(UnitGUID and UnitGUID("player") or UnitName and UnitName("player") or "current")
    if not identity or identity=="" then identity="current" end
    local stats=Stats()
    local profile=RPAL.GetProfile and RPAL.GetProfile() or {}
    local snap={
        identityKey=identity,
        displayName=(RPAL.FullName and RPAL.FullName()) or tostring(profile.firstName or UnitName and UnitName("player") or "Character"),
        lastSeenAt=time and time() or 0,
        charactersMet=math.max(0,tonumber(stats.charactersMet) or 0),
        contacts=CopyTruthyKeys(stats.metPlayers),
        interactions=math.max(0,tonumber(stats.totalEvents) or 0),
        sessions=SessionMetric("count"),
        sessionSeconds=SessionMetric("duration"),
        sessionInteractions=SessionMetric("interactions"),
        memories=MomentCount(),
        bonds=BondCount(),
        arcs=CompletedArcCount(),
        campaignsJoined=CampaignMetric("joined"),
        campaignsFinished=CampaignMetric("finished"),
        campaignSessions=CampaignMetric("sessions"),
        npcsSaved=CampaignMetric("saved"),
        npcsKilled=CampaignMetric("killed"),
        renownScore=math.max(0,tonumber(stats.score) or 0),
        renownLevel=RenownLevel(),
        cosmetics=CosmeticCount(),
        calendarEvents=CalendarCount(),
    }
    snap.active=(snap.sessions>0 or snap.interactions>0 or snap.charactersMet>0 or snap.memories>0 or snap.campaignsJoined>0 or snap.renownScore>0)
    ledger.identities[identity]=snap
    ledger.lastUpdatedAt=snap.lastSeenAt
    RPAL._accountAchievementAggregateCache=nil
    return snap
end

local function AccountAggregate()
    if RPAL._accountAchievementAggregateCache then return RPAL._accountAchievementAggregateCache end
    local ledger=type(RPAL_DB)=="table" and RPAL_DB.accountAchievementLedger or nil
    local identities=type(ledger)=="table" and type(ledger.identities)=="table" and ledger.identities or {}
    local a={identities=0,active=0,contacts=0,interactions=0,sessions=0,sessionSeconds=0,memories=0,arcs=0,campaignsJoined=0,campaignsFinished=0,campaignSessions=0,npcsSaved=0,npcsKilled=0,renownScore=0,maxLevel=0,level20=0,level30=0,level40=0,level60=0}
    local contacts={}
    for _,snap in pairs(identities) do
        if type(snap)=="table" then
            a.identities=a.identities+1
            if snap.active then a.active=a.active+1 end
            a.interactions=a.interactions+math.max(0,tonumber(snap.interactions) or 0)
            a.sessions=a.sessions+math.max(0,tonumber(snap.sessions) or 0)
            a.sessionSeconds=a.sessionSeconds+math.max(0,tonumber(snap.sessionSeconds) or 0)
            a.memories=a.memories+math.max(0,tonumber(snap.memories) or 0)
            a.arcs=a.arcs+math.max(0,tonumber(snap.arcs) or 0)
            a.campaignsJoined=a.campaignsJoined+math.max(0,tonumber(snap.campaignsJoined) or 0)
            a.campaignsFinished=a.campaignsFinished+math.max(0,tonumber(snap.campaignsFinished) or 0)
            a.campaignSessions=a.campaignSessions+math.max(0,tonumber(snap.campaignSessions) or 0)
            a.npcsSaved=a.npcsSaved+math.max(0,tonumber(snap.npcsSaved) or 0)
            a.npcsKilled=a.npcsKilled+math.max(0,tonumber(snap.npcsKilled) or 0)
            a.renownScore=a.renownScore+math.max(0,tonumber(snap.renownScore) or 0)
            local level=math.max(1,tonumber(snap.renownLevel) or 1)
            if level>a.maxLevel then a.maxLevel=level end
            if level>=20 then a.level20=a.level20+1 end
            if level>=30 then a.level30=a.level30+1 end
            if level>=40 then a.level40=a.level40+1 end
            if level>=60 then a.level60=a.level60+1 end
            for key,value in pairs(type(snap.contacts)=="table" and snap.contacts or {}) do if value then contacts[key]=true end end
        end
    end
    a.contacts=CountMap(contacts)
    RPAL._accountAchievementAggregateCache=a
    return a
end

local function HearthstoneCosmeticMetric(kind)
    local HS=RPAL.Hearthstone
    if not HS or not HS.GetState then return 0 end
    local state=HS.GetState() or {}
    if kind=="arenas" then return CountMap(state.unlockedArenas,function(_,v)return v==true end) end
    if kind=="backs" then return CountMap(state.unlockedCardBacks,function(_,v)return v==true end) end
    return 0
end

local AUTO={}
local ACCOUNT_AUTO={}
local function Rule(id,target,label,measure)
    AUTO[id]={target=target,label=label,measure=measure}
end
local function AccountRule(id,target,label,measure)
    ACCOUNT_AUTO[id]={target=target,label=label,measure=measure,account=true}
end
local function StatRule(id,target,key,label)
    Rule(id,target,label,function()
        local s=Stats();if key=="charactersMet" then return math.max(0,tonumber(s.charactersMet) or 0) end
        if key=="totalEvents" then return math.max(0,tonumber(s.totalEvents) or 0) end
        return math.max(0,tonumber((s.counts or {})[key]) or 0)
    end)
end

-- Character/profile rules ----------------------------------------------------
StatRule("starter-first-contact",1,"charactersMet","unique characters met")
Rule("starter-first-session",1,"Sessions",function()return SessionMetric("count")end)
Rule("starter-first-memory",1,"saved memories",MomentCount)
Rule("starter-first-plan",1,"Calendar events",CalendarCount)
Rule("starter-first-campaign",1,"Campaigns joined",function()return CampaignMetric("joined")end)
Rule("starter-level-five",5,"Renown Level",RenownLevel)
for _,entry in ipairs({{5,"social-met-5"},{10,"social-met-10"},{25,"social-met-25"},{50,"social-met-50"},{100,"social-met-100"},{150,"social-met-150"},{250,"social-met-250"},{500,"social-met-500"}}) do StatRule(entry[2],entry[1],"charactersMet","unique characters met") end
for _,entry in ipairs({{10,"social-events-10"},{25,"social-events-25"},{50,"social-events-50"},{100,"social-events-100"},{250,"social-events-250"},{500,"social-events-500"},{1000,"social-events-1000"},{2500,"social-events-2500"},{5000,"social-events-5000"}}) do StatRule(entry[2],entry[1],"totalEvents","tracked social interactions") end
for _,entry in ipairs({{"smile","social-smile-10"},{"hug","social-hug-10"},{"cheer","social-cheer-10"},{"wave","social-wave-10"},{"peer","social-peer-10"},{"dance","social-dance-10"},{"glance","social-glance-10"}}) do StatRule(entry[2],10,entry[1],"/"..entry[1].." interactions") end
for _,entry in ipairs({{"smile","social-smile-25"},{"hug","social-hug-25"},{"cheer","social-cheer-25"},{"wave","social-wave-25"},{"peer","social-peer-25"},{"dance","social-dance-25"},{"glance","social-glance-25"}}) do StatRule(entry[2],25,entry[1],"/"..entry[1].." interactions") end
Rule("social-variety-5",5,"different social interaction types",SocialVariety);Rule("social-variety-10",10,"different social interaction types",SocialVariety);Rule("social-variety-15",15,"different social interaction types",SocialVariety)
for _,entry in ipairs({{5,"session-5"},{10,"session-10"},{25,"session-25"},{50,"session-50"},{100,"session-100"},{200,"session-200"},{300,"session-300"},{500,"session-500"}}) do Rule(entry[2],entry[1],"completed Sessions",function()return SessionMetric("count")end) end
for _,entry in ipairs({{1800,"session-time-30m"},{7200,"session-time-2h"},{18000,"session-time-5h"},{36000,"session-time-10h"},{90000,"session-time-25h"},{180000,"session-time-50h"},{360000,"session-time-100h"},{900000,"session-time-250h"},{1800000,"session-time-500h"}}) do Rule(entry[2],entry[1],"seconds of recorded Session time",function()return SessionMetric("duration")end) end
Rule("session-single-hour",3600,"seconds in one Session",function()return SessionMetric("maxDuration")end);Rule("session-single-2h",7200,"seconds in one Session",function()return SessionMetric("maxDuration")end);Rule("session-single-3h",10800,"seconds in one Session",function()return SessionMetric("maxDuration")end);Rule("session-single-4h",14400,"seconds in one Session",function()return SessionMetric("maxDuration")end);Rule("session-single-6h",21600,"seconds in one Session",function()return SessionMetric("maxDuration")end)
for _,entry in ipairs({{3,"session-party-3"},{5,"session-party-5"},{10,"session-party-10"},{15,"session-party-15"}}) do Rule(entry[2],entry[1],"active participants in one Session",function()return SessionMetric("participants")end) end
for _,entry in ipairs({{50,"session-interactions-50"},{250,"session-interactions-250"},{500,"session-interactions-500"},{1000,"session-interactions-1000"},{2500,"session-interactions-2500"},{5000,"session-interactions-5000"}}) do Rule(entry[2],entry[1],"Session interactions",function()return SessionMetric("interactions")end) end
Rule("session-resume-3",3,"Session resumes",function()return SessionMetric("resumes")end);Rule("session-resume-10",10,"Session resumes",function()return SessionMetric("resumes")end);Rule("session-resume-25",25,"Session resumes",function()return SessionMetric("resumes")end)
Rule("session-group-10",10,"Sessions with 3+ participants",function()return CountSessionsWithMinParticipants(3)end);Rule("session-group-25",25,"Sessions with 3+ participants",function()return CountSessionsWithMinParticipants(3)end);Rule("session-group-50",50,"Sessions with 3+ participants",function()return CountSessionsWithMinParticipants(3)end);Rule("session-group-100",100,"Sessions with 3+ participants",function()return CountSessionsWithMinParticipants(3)end)
for _,entry in ipairs({{5,"chronicle-memories-5"},{10,"chronicle-memories-10"},{25,"chronicle-memories-25"},{50,"chronicle-memories-50"},{100,"chronicle-memories-100"},{200,"chronicle-memories-200"},{300,"chronicle-memories-300"}}) do Rule(entry[2],entry[1],"saved memories",MomentCount) end
Rule("chronicle-pin-1",1,"pinned memories",PinnedMomentCount);Rule("chronicle-pin-3",3,"pinned memories",PinnedMomentCount)
for _,entry in ipairs({{5,"chronicle-bonds-5"},{15,"chronicle-bonds-15"},{30,"chronicle-bonds-30"},{50,"chronicle-bonds-50"},{100,"chronicle-bonds-100"},{150,"chronicle-bonds-150"},{250,"chronicle-bonds-250"}}) do Rule(entry[2],entry[1],"recorded Bonds",BondCount) end
for _,entry in ipairs({{1,"chronicle-arc-1"},{5,"chronicle-arc-5"},{10,"chronicle-arc-10"},{25,"chronicle-arc-25"},{50,"chronicle-arc-50"}}) do Rule(entry[2],entry[1],"completed Story Arcs",CompletedArcCount) end
for _,entry in ipairs({{3,"campaign-join-3"},{5,"campaign-join-5"},{10,"campaign-join-10"},{25,"campaign-join-25"}}) do Rule(entry[2],entry[1],"Campaigns joined",function()return CampaignMetric("joined")end) end
for _,entry in ipairs({{1,"campaign-finish-1"},{3,"campaign-finish-3"},{5,"campaign-finish-5"},{10,"campaign-finish-10"},{25,"campaign-finish-25"}}) do Rule(entry[2],entry[1],"concluded Campaigns",function()return CampaignMetric("finished")end) end
for _,entry in ipairs({{10,"campaign-sessions-10"},{25,"campaign-sessions-25"},{50,"campaign-sessions-50"}}) do Rule(entry[2],entry[1],"Campaign Sessions",function()return CampaignMetric("sessions")end) end
for _,entry in ipairs({{5,"campaign-saved-5"},{25,"campaign-saved-25"}}) do Rule(entry[2],entry[1],"NPCs saved",function()return CampaignMetric("saved")end) end
for _,entry in ipairs({{25,"campaign-killed-25"},{100,"campaign-killed-100"}}) do Rule(entry[2],entry[1],"NPCs killed",function()return CampaignMetric("killed")end) end
for _,entry in ipairs({{10,"renown-level-10"},{20,"renown-level-20"},{30,"renown-level-30"},{40,"renown-level-40"},{50,"renown-level-50"},{60,"renown-level-60"},{70,"renown-level-70"},{80,"renown-level-80"},{90,"renown-level-90"},{100,"renown-level-100"}}) do Rule(entry[2],entry[1],"Renown Level",RenownLevel) end
for _,entry in ipairs({{500,"renown-score-500"},{1000,"renown-score-1000"},{2500,"renown-score-2500"},{5000,"renown-score-5000"},{10000,"renown-score-10000"},{25000,"renown-score-25000"},{50000,"renown-score-50000"},{100000,"renown-score-100000"}}) do Rule(entry[2],entry[1],"total Renown",function()return math.max(0,tonumber(Stats().score) or 0)end) end
for _,entry in ipairs({{10,"renown-cosmetics-10"},{25,"renown-cosmetics-25"},{50,"renown-cosmetics-50"},{75,"renown-cosmetics-75"}}) do Rule(entry[2],entry[1],"unlocked cosmetics",CosmeticCount) end
for _,entry in ipairs({{3,"planning-3"},{5,"planning-5"},{10,"planning-10"},{25,"planning-25"},{50,"planning-50"},{100,"planning-100"},{250,"planning-250"}}) do Rule(entry[2],entry[1],"Calendar events",CalendarCount) end
Rule("health-injury-1",1,"active injury markers",function()return InjuryMetric("count")end);Rule("health-injury-3",3,"active injury markers",function()return InjuryMetric("count")end);Rule("health-injury-5",5,"active injury markers",function()return InjuryMetric("count")end)
Rule("health-healing",1,"Healing Status entries",function()return InjuryMetric("healing")end);Rule("health-recovery-timer",1,"active recovery timers",function()return InjuryMetric("timer")end)
for _,entry in ipairs({{1,"minigame-hs-match-1"},{10,"minigame-hs-match-10"},{25,"minigame-hs-match-25"},{50,"minigame-hs-match-50"},{100,"minigame-hs-match-100"}}) do Rule(entry[2],entry[1],"Unofficial Hearthstone matches",function()return HearthstoneMetric("matches")end) end
for _,entry in ipairs({{1,"minigame-hs-win-1"},{5,"minigame-hs-win-5"},{10,"minigame-hs-win-10"},{25,"minigame-hs-win-25"},{50,"minigame-hs-win-50"}}) do Rule(entry[2],entry[1],"Unofficial Hearthstone wins",function()return HearthstoneMetric("wins")end) end
Rule("minigame-hs-lethal-5",5,"lethal Unofficial Hearthstone wins",function()return HearthstoneMetric("lethal")end)
for _,entry in ipairs({{5,"minigame-hs-packs-5"},{25,"minigame-hs-packs-25"},{50,"minigame-hs-packs-50"},{100,"minigame-hs-packs-100"}}) do Rule(entry[2],entry[1],"card packs opened",function()return HearthstoneMetric("packs")end) end

-- Account-wide rules ---------------------------------------------------------
for _,entry in ipairs({{2,"account-identities-2"},{3,"account-identities-3"},{5,"account-identities-5"},{10,"account-identities-10"}}) do AccountRule(entry[2],entry[1],"recorded ODYN identities",function()return AccountAggregate().identities end) end
AccountRule("account-main-selected",1,"Main character selected",function()return RPAL.GetMainCharacterInfo and RPAL.GetMainCharacterInfo() and 1 or 0 end)
AccountRule("account-active-3",3,"identities with RP activity",function()return AccountAggregate().active end);AccountRule("account-active-5",5,"identities with RP activity",function()return AccountAggregate().active end)
for _,entry in ipairs({{25,"account-sessions-25"},{50,"account-sessions-50"},{100,"account-sessions-100"},{250,"account-sessions-250"},{500,"account-sessions-500"}}) do AccountRule(entry[2],entry[1],"Sessions across the account",function()return AccountAggregate().sessions end) end
for _,entry in ipairs({{90000,"account-hours-25"},{180000,"account-hours-50"},{360000,"account-hours-100"},{900000,"account-hours-250"},{1800000,"account-hours-500"}}) do AccountRule(entry[2],entry[1],"seconds of Session time across the account",function()return AccountAggregate().sessionSeconds end) end
for _,entry in ipairs({{25,"account-memories-25"},{50,"account-memories-50"},{100,"account-memories-100"}}) do AccountRule(entry[2],entry[1],"saved memories across the account",function()return AccountAggregate().memories end) end
for _,entry in ipairs({{10,"account-arcs-10"},{25,"account-arcs-25"}}) do AccountRule(entry[2],entry[1],"completed Story Arcs across the account",function()return AccountAggregate().arcs end) end
for _,entry in ipairs({{50,"account-contacts-50"},{100,"account-contacts-100"},{250,"account-contacts-250"},{500,"account-contacts-500"}}) do AccountRule(entry[2],entry[1],"unique contacts across the roster",function()return AccountAggregate().contacts end) end
for _,entry in ipairs({{500,"account-interactions-500"},{1000,"account-interactions-1000"},{2500,"account-interactions-2500"},{5000,"account-interactions-5000"}}) do AccountRule(entry[2],entry[1],"social interactions across the account",function()return AccountAggregate().interactions end) end
AccountRule("account-social-roster-3",3,"identities with 10+ contacts",function()local n=0;for _,s in pairs((RPAL_DB.accountAchievementLedger and RPAL_DB.accountAchievementLedger.identities) or {})do if (tonumber(s.charactersMet)or 0)>=10 then n=n+1 end end;return n end)
AccountRule("account-social-roster-5",5,"identities with 10+ contacts",function()local n=0;for _,s in pairs((RPAL_DB.accountAchievementLedger and RPAL_DB.accountAchievementLedger.identities) or {})do if (tonumber(s.charactersMet)or 0)>=10 then n=n+1 end end;return n end)
AccountRule("account-one-level-20",20,"highest Renown Level on the roster",function()return AccountAggregate().maxLevel end);AccountRule("account-one-level-40",40,"highest Renown Level on the roster",function()return AccountAggregate().maxLevel end);AccountRule("account-one-level-60",60,"highest Renown Level on the roster",function()return AccountAggregate().maxLevel end)
AccountRule("account-two-level-20",2,"identities at Renown Level 20+",function()return AccountAggregate().level20 end);AccountRule("account-three-level-20",3,"identities at Renown Level 20+",function()return AccountAggregate().level20 end);AccountRule("account-three-level-30",3,"identities at Renown Level 30+",function()return AccountAggregate().level30 end);AccountRule("account-five-level-20",5,"identities at Renown Level 20+",function()return AccountAggregate().level20 end)
for _,entry in ipairs({{2500,"account-renown-2500"},{5000,"account-renown-5000"},{10000,"account-renown-10000"}}) do AccountRule(entry[2],entry[1],"combined Renown across the roster",function()return AccountAggregate().renownScore end) end
for _,entry in ipairs({{10,"account-campaign-joined-10"},{25,"account-campaign-joined-25"},{50,"account-campaign-joined-50"}}) do AccountRule(entry[2],entry[1],"Campaigns joined across the account",function()return AccountAggregate().campaignsJoined end) end
for _,entry in ipairs({{5,"account-campaign-finished-5"},{10,"account-campaign-finished-10"},{25,"account-campaign-finished-25"}}) do AccountRule(entry[2],entry[1],"concluded Campaigns across the account",function()return AccountAggregate().campaignsFinished end) end
AccountRule("account-campaign-sessions-50",50,"Campaign Sessions across the account",function()return AccountAggregate().campaignSessions end);AccountRule("account-campaign-saved-25",25,"NPCs saved across the account",function()return AccountAggregate().npcsSaved end);AccountRule("account-campaign-killed-100",100,"NPCs killed across the account",function()return AccountAggregate().npcsKilled end)
AccountRule("account-hs-matches-25",25,"Unofficial Hearthstone matches",function()return HearthstoneMetric("matches")end);AccountRule("account-hs-matches-100",100,"Unofficial Hearthstone matches",function()return HearthstoneMetric("matches")end)
AccountRule("account-hs-wins-25",25,"Unofficial Hearthstone wins",function()return HearthstoneMetric("wins")end);AccountRule("account-hs-wins-100",100,"Unofficial Hearthstone wins",function()return HearthstoneMetric("wins")end)
AccountRule("account-hs-packs-50",50,"card packs opened",function()return HearthstoneMetric("packs")end);AccountRule("account-hs-packs-100",100,"card packs opened",function()return HearthstoneMetric("packs")end)
AccountRule("account-hs-arenas-5",5,"unlocked Unofficial Hearthstone arenas",function()return HearthstoneCosmeticMetric("arenas")end);AccountRule("account-hs-backs-10",10,"unlocked Unofficial Hearthstone card backs",function()return HearthstoneCosmeticMetric("backs")end);AccountRule("account-hs-backs-18",18,"unlocked Unofficial Hearthstone card backs",function()return HearthstoneCosmeticMetric("backs")end)
for _,entry in ipairs({{1000,"account-tokens-1000"},{5000,"account-tokens-5000"},{10000,"account-tokens-10000"}}) do AccountRule(entry[2],entry[1],"Tokens in the shared reserve",function()return RPAL.GetCrests and RPAL.GetCrests() or 0 end) end

RPAL.AUTO_ACHIEVEMENTS=AUTO
RPAL.ACCOUNT_AUTO_ACHIEVEMENTS=ACCOUNT_AUTO

function RPAL.IsAutomaticAchievement(badgeID)
    badgeID=tostring(badgeID or "")
    return AUTO[badgeID]~=nil or ACCOUNT_AUTO[badgeID]~=nil
end

function RPAL.GetAutomaticAchievementScope(badgeID)
    badgeID=tostring(badgeID or "")
    if ACCOUNT_AUTO[badgeID] then return "account" end
    if AUTO[badgeID] then return "character" end
end

function RPAL.GetAutomaticAchievementStatus(badgeID)
    badgeID=tostring(badgeID or "")
    local rule=AUTO[badgeID] or ACCOUNT_AUTO[badgeID]
    if not rule then return nil end
    if rule.account and RPAL.CaptureAccountAchievementSnapshot then RPAL.CaptureAccountAchievementSnapshot() end
    local ok,value=pcall(rule.measure)
    value=ok and math.max(0,math.floor(tonumber(value) or 0)) or 0
    local target=math.max(1,math.floor(tonumber(rule.target) or 1))
    return {
        current=value,target=target,label=rule.label or "progress",scope=rule.account and "account" or "character",
        complete=value>=target,
        text=tostring(math.min(value,target)).." / "..tostring(target).." "..tostring(rule.label or "progress"),
    }
end

local function LibraryBadge(library,id)
    for _,category in ipairs(type(library)=="table" and library or {}) do
        for _,badge in ipairs(category.badges or {}) do if badge.id==id then return badge end end
    end
end

local function EnsureProgress(progressTable,id)
    if type(progressTable)~="table" then return nil end
    local p=progressTable[id]
    if not p then p={completed=false,completedAt=nil,note=""};progressTable[id]=p end
    return p
end

-- Automatic milestones pay Renown once, but they are a supporting reward
-- rather than a replacement for actually roleplaying.  HF31's logarithmic
-- formula stacked far too aggressively across an established character's old
-- milestones; this v3 economy keeps starter hits tiny and reserves meaningful
-- payouts for genuinely long-tail goals.
RPAL.AUTOMATIC_ACHIEVEMENT_RENOWN_VERSION=3

local function RoundAchievementRenown(value)
    value=math.max(5,tonumber(value) or 5)
    return math.max(5,math.floor((value+2.5)/5)*5)
end

function RPAL.GetAutomaticAchievementRenownReward(id,rule)
    id=tostring(id or "")
    rule=type(rule)=="table" and rule or {}
    local target=math.max(1,tonumber(rule.target) or 1)
    local label=tostring(rule.label or ""):lower()
    local reward

    if id:find("starter%-") then
        reward=5
    elseif label:find("seconds",1,true) then
        local hours=target/3600
        if hours<=1 then reward=5
        elseif hours<=2 then reward=10
        elseif hours<=5 then reward=15
        elseif hours<=10 then reward=20
        elseif hours<=25 then reward=25
        elseif hours<=50 then reward=35
        elseif hours<=100 then reward=45
        elseif hours<=250 then reward=60
        else reward=75 end
    elseif label:find("renown level",1,true) or id:find("renown%-level") or id:find("account%-one%-level") then
        if target<=10 then reward=5
        elseif target<=20 then reward=10
        elseif target<=30 then reward=15
        elseif target<=40 then reward=20
        elseif target<=50 then reward=25
        elseif target<=60 then reward=35
        elseif target<=70 then reward=45
        elseif target<=80 then reward=60
        elseif target<=90 then reward=75
        else reward=100 end
    elseif label:find("total renown",1,true) or label:find("combined renown",1,true) or id:find("renown%-score") or id:find("account%-renown") then
        if target<=500 then reward=5
        elseif target<=1000 then reward=5
        elseif target<=2500 then reward=10
        elseif target<=5000 then reward=15
        elseif target<=10000 then reward=20
        elseif target<=25000 then reward=30
        elseif target<=50000 then reward=40
        else reward=50 end
    else
        if target<=3 then reward=5
        elseif target<=10 then reward=5
        elseif target<=25 then reward=10
        elseif target<=50 then reward=15
        elseif target<=100 then reward=20
        elseif target<=250 then reward=25
        elseif target<=500 then reward=35
        elseif target<=1000 then reward=45
        elseif target<=2500 then reward=55
        elseif target<=5000 then reward=70
        else reward=85 end
    end

    if rule.account then reward=reward*1.10 end
    return RoundAchievementRenown(math.min(100,reward))
end

-- One-time HF31 economy correction. Every automatic-achievement Renown claim
-- before v3 came from the over-generous earlier implementations, so the claimed
-- amount stored on each badge lets us remove only the excess while preserving
-- all Renown earned from Sessions, interactions, Talents and other systems.
-- Character claims are always safe to reconcile. Account claims are included
-- only when their timestamp matches the character's original payout batch,
-- which identifies the character that actually received that shared reward.
function RPAL.RebalanceAutomaticAchievementRenown()
    if not RPAL.char then return 0 end
    local stats=RPAL.GetStatistics and RPAL.GetStatistics()
    if not stats or tonumber(stats.automaticAchievementRenownEconomyVersion)==RPAL.AUTOMATIC_ACHIEVEMENT_RENOWN_VERSION then return 0 end

    local correction=0
    local batchTimes={}
    local function Reprice(progressTable,rules,includeCorrection,captureTimes)
        if type(progressTable)~="table" then return end
        for id,rule in pairs(rules or {}) do
            local progress=progressTable[id]
            if type(progress)=="table" and progress.renownRewardClaimed and (tonumber(progress.renownRewardEconomyVersion) or 1)<RPAL.AUTOMATIC_ACHIEVEMENT_RENOWN_VERSION then
                local oldReward=math.max(0,math.floor(tonumber(progress.renownReward) or 0))
                local newReward=RPAL.GetAutomaticAchievementRenownReward(id,rule)
                local claimedAt=math.floor(tonumber(progress.renownRewardClaimedAt) or 0)
                if captureTimes and claimedAt>0 then batchTimes[claimedAt]=true end
                local shouldCorrect=includeCorrection
                if includeCorrection=="matching" then
                    shouldCorrect=false
                    for ts in pairs(batchTimes) do if math.abs(ts-claimedAt)<=2 then shouldCorrect=true break end end
                end
                if shouldCorrect and oldReward>newReward then correction=correction+(oldReward-newReward) end
                progress.renownRewardLegacyV1=oldReward
                progress.renownReward=newReward
                progress.renownRewardEconomyVersion=RPAL.AUTOMATIC_ACHIEVEMENT_RENOWN_VERSION
            end
        end
    end

    Reprice(RPAL.char.progress,AUTO,true,true)
    if type(RPAL_DB)=="table" then
        RPAL_DB.accountProgress=type(RPAL_DB.accountProgress)=="table" and RPAL_DB.accountProgress or {}
        Reprice(RPAL_DB.accountProgress,ACCOUNT_AUTO,"matching",false)
    end

    correction=math.min(math.max(0,math.floor(correction)),math.max(0,math.floor(tonumber(stats.score) or 0)))
    if correction>0 then
        stats.score=math.max(0,(tonumber(stats.score) or 0)-correction)
        stats.automaticAchievementRenownCorrection=correction
        stats.automaticAchievementRenownCorrectedAt=time and time() or 0
    end
    stats.automaticAchievementRenownEconomyVersion=RPAL.AUTOMATIC_ACHIEVEMENT_RENOWN_VERSION
    if correction>0 and DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
        DEFAULT_CHAT_FRAME:AddMessage("|cff66e6ffODYN:|r Achievement Renown was rebalanced; "..tostring(correction).." excess Renown from the Hotfix 31 payout was removed. Non-achievement Renown was preserved.")
    end
    if correction>0 and RPAL.Refresh then pcall(RPAL.Refresh) end
    return correction
end

-- Cosmetic rewards moved out of the Token shop. These are automatic
-- character-achievement payoffs and reconcile retroactively for existing saves.
RPAL.ACHIEVEMENT_COSMETIC_REWARDS={
    ["starter-level-five"]={{kind="theme",name="Midnight"},{kind="animation",name="Talk"}},
    ["social-met-25"]={{kind="theme",name="Stormwind"},{kind="platform",name="Runic Halo"}},
    ["session-25"]={{kind="background",name="Candlelit Parchment"},{kind="animation",name="Eat"}},
    ["session-time-10h"]={{kind="theme",name="Monochrome"},{kind="effect",name="Rainfall"}},
    ["chronicle-memories-25"]={{kind="theme",name="Rosewood"},{kind="ornament",name="Runic Corners"}},
    ["renown-level-20"]={{kind="theme",name="Bronze"},{kind="portrait",name="Arcane"}},
    ["social-events-250"]={{kind="theme",name="Drust"},{kind="animation",name="Cheer"},{kind="background",name="Amber Archive"}},
    ["session-50"]={{kind="portrait",name="Frostbound"},{kind="animation",name="Work"}},
    ["chronicle-bonds-50"]={{kind="header",name="Arcane"},{kind="aura",name="Blood Oath"}},
    ["renown-score-2500"]={{kind="aura",name="Frost Veil"},{kind="effect",name="Ashen Rain"},{kind="background",name="Runic Haze"}},
    ["planning-25"]={{kind="background",name="Graveyard Fog"},{kind="animation",name="Bow"}},
    ["minigame-hs-win-10"]={{kind="animation",name="Dance"},{kind="animation",name="Laugh"},{kind="effect",name="Lantern Motes"}},
    ["social-smile-25"]={{kind="toast",name="Crimson Dispatch"},{kind="focus",name="Moonlit Trace"}},
    ["social-peer-25"]={{kind="eye",name="Runebound Lens"}},
    ["social-glance-25"]={{kind="eye",name="Voidglass Eye"}},
    ["chronicle-memories-50"]={{kind="header",name="Heirloom Crest"}},
}

local ACHIEVEMENT_REWARD_KIND_LABELS={
    theme="Theme",portrait="Portrait Frame",level="Level Badge",
    header="Header Decoration",background="Atmosphere",aura="Profile Aura",
    ornament="Ledger Ornament",platform="Model Platform",
    animation="Character Animation",effect="Effect Pack",toast="Toast Frame",
    tooltip="Tooltip Treatment",focus="Focus Mark",eye="ODYN's Eye",
}

-- Shared by the local Achievement Ledger tooltip (and any future surface that
-- needs to explain an automatic cosmetic payoff). Keep every reward visible:
-- several milestones deliberately grant a coordinated two- or three-piece set.
function RPAL.GetAchievementCosmeticRewardText(achievementID)
    local rewards=RPAL.ACHIEVEMENT_COSMETIC_REWARDS and RPAL.ACHIEVEMENT_COSMETIC_REWARDS[tostring(achievementID or "")]
    if type(rewards)~="table" or #rewards==0 then return nil end
    local lines={}
    for _,reward in ipairs(rewards) do
        if reward and reward.name then
            local kind=tostring(reward.kind or ""):lower()
            local label=ACHIEVEMENT_REWARD_KIND_LABELS[kind] or "Cosmetic"
            lines[#lines+1]="•  "..label..": "..tostring(reward.name)
        end
    end
    return #lines>0 and table.concat(lines,"\n") or nil
end

local ACHIEVEMENT_REWARD_LABELS={
    ["starter-level-five"]="ACHIEVEMENT • FINDING THEIR FEET",
    ["social-met-25"]="ACHIEVEMENT • FAMILIAR FACES",
    ["session-25"]="ACHIEVEMENT • 25 SESSIONS",
    ["session-time-10h"]="ACHIEVEMENT • 10 HOURS",
    ["chronicle-memories-25"]="ACHIEVEMENT • 25 MEMORIES",
    ["renown-level-20"]="ACHIEVEMENT • RENOWN LEVEL 20",
    ["social-events-250"]="ACHIEVEMENT • 250 INTERACTIONS",
    ["session-50"]="ACHIEVEMENT • SEASONED ROLEPLAYER",
    ["chronicle-bonds-50"]="ACHIEVEMENT • 50 BONDS",
    ["renown-score-2500"]="ACHIEVEMENT • 2,500 RENOWN",
    ["planning-25"]="ACHIEVEMENT • 25 CALENDAR EVENTS",
    ["minigame-hs-win-10"]="ACHIEVEMENT • 10 CARD WINS",
    ["social-smile-25"]="ACHIEVEMENT • 25 SMILES",
    ["social-peer-25"]="ACHIEVEMENT • HARD TO IGNORE",
    ["social-glance-25"]="ACHIEVEMENT • SECOND LOOK",
    ["chronicle-memories-50"]="ACHIEVEMENT • 50 MEMORIES",
}
RPAL.ACHIEVEMENT_REWARD_LOOKUP={}
for achievementID,rewards in pairs(RPAL.ACHIEVEMENT_COSMETIC_REWARDS) do
    for _,reward in ipairs(rewards) do
        RPAL.ACHIEVEMENT_REWARD_LOOKUP[tostring(reward.kind)..":"..tostring(reward.name)]={achievement=achievementID,label=ACHIEVEMENT_REWARD_LABELS[achievementID] or "ACHIEVEMENT REWARD"}
    end
end

local function CheckRuleSet(library,progressTable,rules,scopeName,unlockedInfo,rewardClaims,cosmeticClaims)
    for id,rule in pairs(rules) do
        local badge=LibraryBadge(library,id)
        if badge then
            local progress=EnsureProgress(progressTable,id)
            if progress then
                if not progress.completed then
                    local ok,value=pcall(rule.measure)
                    value=ok and math.max(0,math.floor(tonumber(value) or 0)) or 0
                    if value>=math.max(1,math.floor(tonumber(rule.target) or 1)) then
                        progress.completed=true;progress.completedAt=time();progress.automatic=true;progress.scope=scopeName
                        unlockedInfo[#unlockedInfo+1]={badge=badge,scope=scopeName}
                    end
                end
                -- Retroactive-safe: any completed automatic milestone that has
                -- never paid its Renown is queued exactly once, including old saves.
                if progress.completed and not progress.renownRewardClaimed then
                    rewardClaims[#rewardClaims+1]={
                        id=id,rule=rule,progress=progress,badge=badge,scope=scopeName,
                        reward=RPAL.GetAutomaticAchievementRenownReward(id,rule),
                    }
                end
                if progress.completed and not progress.cosmeticRewardsClaimed and RPAL.ACHIEVEMENT_COSMETIC_REWARDS[id] then
                    cosmeticClaims[#cosmeticClaims+1]={id=id,progress=progress,badge=badge,rewards=RPAL.ACHIEVEMENT_COSMETIC_REWARDS[id]}
                end
            end
        end
    end
end

function RPAL.CheckAutomaticAchievements(showToast,refreshUI)
    if RPAL._checkingAutomaticAchievements or not RPAL.char then return 0 end
    if RPAL.ShouldDelayAutomaticAchievementRewards and RPAL.ShouldDelayAutomaticAchievementRewards() then
        RPAL._automaticAchievementCheckHeldForMigration=true
        return 0
    end
    RPAL._automaticAchievementCheckHeldForMigration=false
    RPAL._checkingAutomaticAchievements=true
    if RPAL.CaptureAccountAchievementSnapshot then pcall(RPAL.CaptureAccountAchievementSnapshot) end
    local unlockedInfo,rewardClaims,cosmeticClaims={},{},{}
    CheckRuleSet(RPAL.char.library,RPAL.char.progress,AUTO,"character",unlockedInfo,rewardClaims,cosmeticClaims)
    if type(RPAL_DB)=="table" then
        RPAL_DB.accountProgress=type(RPAL_DB.accountProgress)=="table" and RPAL_DB.accountProgress or {}
        CheckRuleSet(RPAL_DB.accountLibrary,RPAL_DB.accountProgress,ACCOUNT_AUTO,"account",unlockedInfo,rewardClaims,cosmeticClaims)
    end
    RPAL._checkingAutomaticAchievements=false

    local rewardTotal=0
    for _,claim in ipairs(rewardClaims) do rewardTotal=rewardTotal+math.max(0,math.floor(tonumber(claim.reward) or 0)) end
    if rewardTotal>0 and RPAL.AddRenown and not (RPAL.IsLiteKit and RPAL.IsLiteKit()) then
        local gained=RPAL.AddRenown(rewardTotal,"automatic_achievement",{fixed=true,achievementReward=true,skipTokens=true,silentFX=not showToast,suppressLevelUp=not showToast}) or 0
        if gained>0 then
            local now=time()
            for _,claim in ipairs(rewardClaims) do
                claim.progress.renownRewardClaimed=true
                claim.progress.renownReward=claim.reward
                claim.progress.renownRewardClaimedAt=now
                claim.progress.renownRewardEconomyVersion=RPAL.AUTOMATIC_ACHIEVEMENT_RENOWN_VERSION
            end
            if showToast and RPAL.ShowRewardToast then
                local title=#rewardClaims==1 and "Achievement Renown" or (tostring(#rewardClaims).." Achievement Rewards")
                RPAL.ShowRewardToast(title,gained,"MILESTONE REWARD")
            elseif not showToast and DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
                DEFAULT_CHAT_FRAME:AddMessage("|cffd9a644ODYN:|r Retroactive automatic-achievement rewards: +"..tostring(gained).." Renown ("..tostring(#rewardClaims).." milestones).")
            end
        end
    end

    local cosmeticToast={}
    for _,claim in ipairs(cosmeticClaims) do
        local grantedAny=false
        for _,reward in ipairs(claim.rewards or {}) do
            local wasUnlocked=false
            if reward.kind=="theme" then
                wasUnlocked=RPAL.IsThemeUnlocked and RPAL.IsThemeUnlocked(reward.name)
                if RPAL.UnlockTheme then RPAL.UnlockTheme(reward.name,"progression") end
            else
                wasUnlocked=RPAL.IsCosmeticUnlocked and RPAL.IsCosmeticUnlocked(reward.kind,reward.name)
                if RPAL.UnlockCosmetic then RPAL.UnlockCosmetic(reward.kind,reward.name,"progression") end
            end
            if not wasUnlocked then
                grantedAny=true
                cosmeticToast[#cosmeticToast+1]={kind=reward.kind,name=reward.name}
            end
        end
        claim.progress.cosmeticRewardsClaimed=true
        claim.progress.cosmeticRewardsClaimedAt=time()
    end
    if showToast and #cosmeticToast>0 and RPAL.ShowCosmeticRewardToast then
        RPAL.ShowCosmeticRewardToast(cosmeticToast,"ACHIEVEMENT REWARD")
    end

    local unlocked={};for _,entry in ipairs(unlockedInfo) do unlocked[#unlocked+1]=entry.badge end
    if #unlockedInfo>0 then
        if showToast and RPAL.ShowAchievementUnlockedToast then
            for i,entry in ipairs(unlockedInfo) do
                if i<=3 then
                    local title=RPAL.ReplaceName and RPAL.ReplaceName(entry.badge.name or "Achievement") or entry.badge.name or "Achievement"
                    if entry.scope=="account" then title="Account: "..title end
                    RPAL.ShowAchievementUnlockedToast(title)
                end
            end
            if #unlockedInfo>3 and DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then DEFAULT_CHAT_FRAME:AddMessage("|cffd9a644ODYN:|r "..tostring(#unlockedInfo).." automatic achievements unlocked.") end
        end
        if refreshUI and RPAL.Refresh then RPAL.Refresh() end
    elseif rewardTotal>0 and refreshUI and RPAL.Refresh then
        RPAL.Refresh()
    end
    return #unlocked,unlocked,rewardTotal
end

local pendingCheck=false
function RPAL.ScheduleAutomaticAchievementCheck(delay)
    if pendingCheck then return end
    pendingCheck=true
    local function run()
        pendingCheck=false
        RPAL.CheckAutomaticAchievements(RPAL._automaticAchievementsBootstrapped==true,true)
        RPAL._automaticAchievementsBootstrapped=true
    end
    if C_Timer and C_Timer.After then C_Timer.After(tonumber(delay) or .15,run) else run() end
end

local function WrapMutation(owner,name)
    if type(owner)~="table" or type(owner[name])~="function" then return end
    local marker="_achievementWrapped_"..name
    if owner[marker] then return end
    owner[marker]=true
    local original=owner[name]
    owner[name]=function(...)
        local result={original(...)}
        RPAL.ScheduleAutomaticAchievementCheck(.08)
        return unpack(result)
    end
end

for _,name in ipairs({"RecordStatistic","RecordCharacterMet","AddRenown","RegisterDMCampaignJoined","RecordConcludedDMCampaign","EndScene","ResumeStoredScene","CreateMoment","CreateMomentFromScene","SetMomentPinned","CreateStoryArc","AdvanceStoryArc","ToggleStoryArcComplete","AddCalendarEvent"}) do
    WrapMutation(RPAL,name)
end
if RPAL.Hearthstone then
    for _,name in ipairs({"OpenPack","FinishMatch"}) do WrapMutation(RPAL.Hearthstone,name) end
end

-- Silent retroactive pass after login, then a lightweight safety ticker.  The
-- ticker also catches systems loaded later (notably Injury UI) without forcing
-- each subsystem to know about achievements.
local eventFrame=CreateFrame and CreateFrame("Frame")
if eventFrame then
    eventFrame:RegisterEvent("PLAYER_LOGIN")
    eventFrame:SetScript("OnEvent",function()
        if C_Timer and C_Timer.After then
            -- Correct the HF31 achievement payout before any new reward toast
            -- or level-up sequence can fire.  The visible curve-recalibration
            -- panel is then given the stage to itself; its completion explicitly
            -- releases the retroactive achievement pass.
            C_Timer.After(.55,function()
                RPAL.UpgradeAchievementCatalog()
                if RPAL.RebalanceAutomaticAchievementRenown then RPAL.RebalanceAutomaticAchievementRenown() end
            end)
            C_Timer.After(6.4,function()
                RPAL.CheckAutomaticAchievements(false,false)
                RPAL._automaticAchievementsBootstrapped=true
            end)
            if C_Timer.NewTicker and not RPAL._automaticAchievementTicker then
                -- Mutating systems schedule an immediate check through the
                -- wrappers above. This slower pass is only a compatibility net
                -- for modules loaded later or third-party calls we cannot wrap.
                RPAL._automaticAchievementTicker=C_Timer.NewTicker(30,function()
                    RPAL.CheckAutomaticAchievements(true,true)
                end)
            end
        else
            RPAL.UpgradeAchievementCatalog();if RPAL.RebalanceAutomaticAchievementRenown then RPAL.RebalanceAutomaticAchievementRenown() end;RPAL.CheckAutomaticAchievements(false,false);RPAL._automaticAchievementsBootstrapped=true
        end
    end)
end
