local _, ns = ...
local MM = ns.MM

if not MM then
    return
end

local DEFAULT_POINTS_BY_TIER = {
    [1] = 5,
    [2] = 10,
    [3] = 15,
    [4] = 20,
    [5] = 25,
}

local RARITY_RANK = {
    common = 1,
    uncommon = 2,
    rare = 3,
    epic = 4,
    legendary = 5,
    rarest = 6,
}

local STREAK_DEFAULT_TICKS = {
    all_250 = 60,
    all_500 = 45,
    all_750 = 30,
    hunger_950 = 30,
    joy_950 = 30,
    energy_950 = 30,
    cleanliness_950 = 30,
}

local SUBCATEGORY_ICONS = {
    ["General:Progression"] = "Interface\\Icons\\INV_Egg_03",
    ["General:Hatch Rarity"] = "Interface\\Icons\\INV_Misc_Head_Murloc_01",
    ["General:Need States"] = "Interface\\Icons\\Achievement_General_StayClassy",
    ["General:Trait Pairs"] = "Interface\\Icons\\Achievement_General_WorkingasaTeam",
    -- Legacy-safe mappings if these ever appear as top-level categories.
    ["Need States:Need States"] = "Interface\\Icons\\Achievement_General_StayClassy",
    ["Trait Pairs:Trait Pairs"] = "Interface\\Icons\\Achievement_General_WorkingasaTeam",
    ["Caretaking:Caretaking"] = "Interface\\Icons\\INV_Misc_Food_19",
    ["Scavenging:Scavenging"] = "Interface\\Icons\\INV_Misc_Bag_08",
    ["Challenges:Challenges"] = "Interface\\Icons\\Achievement_Quests_Completed_08",
    ["Economy:Economy"] = "Interface\\Icons\\INV_Misc_Coin_01",
    ["Collections:Food"] = "Interface\\Icons\\INV_Misc_Food_65",
    ["Collections:Play Items"] = "Interface\\Icons\\INV_Misc_Primitive_Toy05",
    ["Collections:Rest Items"] = "Interface\\Icons\\INV_Misc_ArmorKit_30",
    ["Collections:Clean Items"] = "Interface\\Icons\\INV_Misc_ArmorKit_05",
    ["Collections:Habitat Upgrades"] = "Interface\\Icons\\INV_Crate_03",
    ["Collections:Habitats"] = "Interface\\Icons\\INV_Misc_Map_01",
    ["Collections:Trinkets"] = "Interface\\Icons\\INV_Jewelry_Necklace_17",
    ["Collections:Purchases"] = "Interface\\Icons\\INV_Misc_Coin_01",
    ["Collections:Completion"] = "Interface\\Icons\\Achievement_General",
    ["Mishaps:Mishaps"] = "Interface\\Icons\\Ability_Creature_Cursed_04",
}

local CATEGORY_DEFAULT_ICONS = {
    ["General"] = "Interface\\Icons\\Achievement_General",
    ["Need States"] = "Interface\\Icons\\Achievement_General_StayClassy",
    ["Trait Pairs"] = "Interface\\Icons\\Achievement_General_WorkingasaTeam",
    ["Caretaking"] = "Interface\\Icons\\INV_Misc_Food_19",
    ["Scavenging"] = "Interface\\Icons\\INV_Misc_Bag_08",
    ["Challenges"] = "Interface\\Icons\\INV_Scroll_03",
    ["Economy"] = "Interface\\Icons\\INV_Misc_Coin_01",
    ["Collections"] = "Interface\\Icons\\Achievement_Character_Draenei_Female",
    ["Mishaps"] = "Interface\\Icons\\Ability_Creature_Cursed_04",
}

local catalog_built = false
local catalog_list = {}
local catalog_by_id = {}
local category_list = {}
local total_possible_points = 0

local function TrimString(str)
    return str and tostring(str):match("^%s*(.-)%s*$") or ""
end

local function CanonicalTraitPairKey(primary, secondary)
    local first = string.lower(TrimString(primary))
    local second = string.lower(TrimString(secondary))
    if first == "" or second == "" or first == second then
        return nil
    end
    if first < second then
        return first .. "_" .. second
    end
    return second .. "_" .. first
end

local function ParseNumberThreshold(text)
    local raw = TrimString(text)
    if raw == "" then
        return nil
    end
    raw = raw:gsub(",", "")
    local num = tonumber(raw)
    if num then
        return num
    end
    local token = raw:match("([-+]?%d+%.?%d*)")
    if token then
        return tonumber(token)
    end
    return nil
end

local function ParseDurationTicks(text)
    local raw = string.lower(TrimString(text))
    if raw == "" then
        return nil
    end

    local mins, secs = raw:match("^(%d+)%s*m%s*(%d+)%s*s$")
    if mins and secs then
        return math.max(1, math.floor((((tonumber(mins) * 60) + tonumber(secs)) / 10) + 0.5))
    end

    mins, secs = raw:match("^(%d+)m(%d+)s$")
    if mins and secs then
        return math.max(1, math.floor((((tonumber(mins) * 60) + tonumber(secs)) / 10) + 0.5))
    end

    mins = raw:match("^(%d+)%s*min") or raw:match("^(%d+)%s*m$")
    if mins then
        return math.max(1, math.floor(((tonumber(mins) * 60) / 10) + 0.5))
    end

    secs = raw:match("^(%d+)%s*sec") or raw:match("^(%d+)%s*s$")
    if secs then
        return math.max(1, math.floor((tonumber(secs) / 10) + 0.5))
    end

    return nil
end

local function FormatTicksAsTime(ticks)
    local totalSeconds = math.max(0, math.floor((tonumber(ticks) or 0) * 10))
    local mins = math.floor(totalSeconds / 60)
    local secs = totalSeconds % 60
    return string.format("%d:%02d", mins, secs)
end

local function BuildCatalog()
    if catalog_built then
        return
    end
    catalog_built = true

    catalog_list = {}
    catalog_by_id = {}
    category_list = {}
    total_possible_points = 0

    local source = ns and ns.AchievementCatalog
    local list = source and source.list or {}
    local points_by_tier = source and source.points_by_tier or DEFAULT_POINTS_BY_TIER

    local category_map = {}

    for _, raw in ipairs(list) do
        local id = TrimString(raw.id)
        if id ~= "" and not catalog_by_id[id] then
            local category = TrimString(raw.category)
            if category == "" then
                category = "General"
            end
            local subcategory = TrimString(raw.subcategory)
            if subcategory == "" then
                subcategory = "General"
            end
            local tier = math.floor(tonumber(raw.tier) or 1)
            if tier < 1 then tier = 1 end
            if tier > 5 then tier = 5 end
            local points = math.floor(tonumber(raw.points) or points_by_tier[tier] or DEFAULT_POINTS_BY_TIER[tier] or 5)
            if points < 0 then points = 0 end

            local entry = {
                id = id,
                name = TrimString(raw.name) ~= "" and TrimString(raw.name) or id,
                category = category,
                subcategory = subcategory,
                tier = tier,
                points = points,
                criteria = TrimString(raw.criteria),
                metric = TrimString(raw.metric),
                threshold = TrimString(raw.threshold),
                tracking_source = TrimString(raw.tracking_source),
                threshold_num = ParseNumberThreshold(raw.threshold),
                threshold_ticks = ParseDurationTicks(raw.threshold),
            }

            catalog_by_id[id] = entry
            catalog_list[#catalog_list + 1] = entry
            total_possible_points = total_possible_points + points

            local cat_data = category_map[category]
            if not cat_data then
                cat_data = {
                    key = category,
                    label = category,
                    subcategories = {},
                    _sub_map = {},
                }
                category_map[category] = cat_data
                category_list[#category_list + 1] = cat_data
            end

            if not cat_data._sub_map[subcategory] then
                local sub_data = {
                    key = subcategory,
                    label = subcategory,
                }
                cat_data._sub_map[subcategory] = sub_data
                cat_data.subcategories[#cat_data.subcategories + 1] = sub_data
            end
        end
    end

    for _, cat in ipairs(category_list) do
        cat._sub_map = nil
    end
end

local function EnsureAchievementState(addon)
    addon.db = addon.db or {}
    addon.db.profile = addon.db.profile or {}
    addon.db.profile.achievements = addon.db.profile.achievements or {}

    local state = addon.db.profile.achievements
    state.unlocked = state.unlocked or {}
    state.points = math.floor(tonumber(state.points) or 0)
    state.recent_unlocks = state.recent_unlocks or {}
    state.unlockSequenceCounter = math.floor(tonumber(state.unlockSequenceCounter) or 0)
    state.streak_ticks = state.streak_ticks or {}

    local max_sequence = state.unlockSequenceCounter
    local missing_sequence = {}
    for achievement_id, unlocked_entry in pairs(state.unlocked) do
        if type(unlocked_entry) == "table" then
            local sequence = math.floor(tonumber(unlocked_entry.unlockSequence or unlocked_entry.sequence or unlocked_entry.order) or 0)
            if sequence > 0 then
                unlocked_entry.unlockSequence = sequence
                if sequence > max_sequence then
                    max_sequence = sequence
                end
            else
                missing_sequence[#missing_sequence + 1] = {
                    id = achievement_id,
                    entry = unlocked_entry,
                }
            end
        end
    end

    if #missing_sequence > 0 then
        local missing_by_id = {}
        for _, row in ipairs(missing_sequence) do
            missing_by_id[row.id] = row.entry
        end

        -- Preserve known unlock order for recent IDs (oldest -> newest).
        for _, recent_id in ipairs(state.recent_unlocks or {}) do
            local entry = missing_by_id[recent_id]
            if entry then
                max_sequence = max_sequence + 1
                entry.unlockSequence = max_sequence
                missing_by_id[recent_id] = nil
            end
        end

        -- Backfill any remaining rows by timestamp.
        local remaining = {}
        for id, entry in pairs(missing_by_id) do
            remaining[#remaining + 1] = { id = id, entry = entry }
        end
        table.sort(remaining, function(a, b)
            local unlocked_a = tonumber(a.entry.unlockedAt) or 0
            local unlocked_b = tonumber(b.entry.unlockedAt) or 0
            if unlocked_a ~= unlocked_b then
                return unlocked_a < unlocked_b
            end
            return tostring(a.id) < tostring(b.id)
        end)
        for _, row in ipairs(remaining) do
            max_sequence = max_sequence + 1
            row.entry.unlockSequence = max_sequence
        end
    end
    state.unlockSequenceCounter = max_sequence

    local streak = state.streak_ticks
    for key, default_value in pairs(STREAK_DEFAULT_TICKS) do
        if streak[key] == nil then
            streak[key] = 0
        else
            streak[key] = math.max(0, math.floor(tonumber(streak[key]) or 0))
        end
        if default_value and default_value < 1 then
            streak[key] = 0
        end
    end

    return state
end

local function EnsureAchievementUIState(addon)
    addon.db = addon.db or {}
    addon.db.profile = addon.db.profile or {}
    addon.db.profile.ui = addon.db.profile.ui or {}
    addon.db.profile.ui.achievementCollapsedCategories = addon.db.profile.ui.achievementCollapsedCategories or {}
    return addon.db.profile.ui
end

local function GetMetricData(addon)
    local pet = (addon.GetPet and addon:GetPet()) or {}
    local store = (addon.GetStore and addon:GetStore()) or {}
    local economy = (addon.GetEconomy and addon:GetEconomy()) or {}
    local stats = (addon.GetInteractionStats and addon:GetInteractionStats()) or {}
    local customization = store.customization or {}
    local activity_history = (addon.db and addon.db.profile and addon.db.profile.activityHistory) or {}

    local food_set = {}
    for _, item in ipairs((addon.GetFoodItems and addon:GetFoodItems()) or {}) do
        if item and item.key then
            food_set[item.key] = true
        end
    end

    local store_category = {}
    for _, entry in ipairs((addon.GetStoreCatalog and addon:GetStoreCatalog()) or {}) do
        if entry and entry.key then
            store_category[entry.key] = entry.category
        end
    end

    return {
        pet = pet,
        store = store,
        economy = economy,
        stats = stats,
        customization = customization,
        activity_history = activity_history,
        food_set = food_set,
        store_category = store_category,
    }
end

local function GetItemCategory(data, item_key)
    if data.food_set[item_key] then
        return "food"
    end
    if tostring(item_key):match("^habitat_bg_") then
        return "habitat_bg"
    end
    return data.store_category[item_key]
end

local function SumByCategory(data, map, category)
    local total = 0
    for key, value in pairs(map or {}) do
        if GetItemCategory(data, key) == category then
            total = total + (tonumber(value) or 0)
        end
    end
    return total
end

local function SumFoodValues(data, map)
    local total = 0
    for key, value in pairs(map or {}) do
        if data.food_set[key] then
            total = total + (tonumber(value) or 0)
        end
    end
    return total
end

local function CountOwnedKeysByCategory(data, map, category)
    local count = 0
    for key, value in pairs(map or {}) do
        if (tonumber(value) or 0) > 0 and GetItemCategory(data, key) == category then
            count = count + 1
        end
    end
    return count
end

local function CountOwnedFoodKeys(data, map)
    local count = 0
    for key, value in pairs(map or {}) do
        if (tonumber(value) or 0) > 0 and data.food_set[key] then
            count = count + 1
        end
    end
    return count
end

local function CountOwnedStoreEntries(addon, category)
    local count = 0
    for _, entry in ipairs((addon.GetStoreCatalog and addon:GetStoreCatalog()) or {}) do
        if entry and entry.category == category and addon.GetStoreItemCount and addon:GetStoreItemCount(entry.key) > 0 then
            count = count + 1
        end
    end
    return count
end

local function CountTrueValues(map, exclude_auto)
    local count = 0
    for key, value in pairs(map or {}) do
        if (not exclude_auto or key ~= "auto") and value == true then
            count = count + 1
        end
    end
    return count
end

local function EvaluateMetric(addon, def, context, data)
    local metric = def.metric
    local threshold_num = def.threshold_num
    local pet = data.pet
    local store = data.store
    local economy = data.economy
    local stats = data.stats

    if metric == "pet.stage == 'adolescent'" then
        local current = (pet.stage == "adolescent") and 1 or 0
        return current, 1, current >= 1
    end

    if metric == "TrimString(pet.name) ~= ''" then
        local current = (TrimString(pet.name) ~= "") and 1 or 0
        return current, 1, current >= 1
    end

    if metric == "activity_message_opened_full_store_panel" then
        local message = string.lower(TrimString(context and context.message or ""))
        local current = (message == "opened full store panel.") and 1 or 0
        return current, 1, current >= 1
    end

    local pair_target = metric:match("^canonical_pair%b()%s*==%s*([%w_]+)$")
    if pair_target then
        local pair_key = CanonicalTraitPairKey(pet.personality, pet.personalitySecondary)
        local current = (pair_key == pair_target) and 1 or 0
        return current, 1, current >= 1
    end

    if metric == "#activityHistory" then
        local current = #(data.activity_history or {})
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "pet.ageMinutes" then
        local current = tonumber(pet.ageMinutes) or 0
        local lifetime = math.max(current, tonumber((stats or {}).petAgeMinutesTotal) or 0)
        local target = threshold_num or 1
        return lifetime, target, lifetime >= target
    end

    local rarity_min = metric:match("^rarity_rank%b()%s*>=%s*([%w_]+)$")
    if rarity_min then
        local current_rank = RARITY_RANK[string.lower(TrimString(pet.modelRarity))] or 0
        local target_rank = RARITY_RANK[string.lower(rarity_min)] or 0
        return current_rank, target_rank, current_rank >= target_rank and target_rank > 0
    end

    local rarity_exact = metric:match("^pet%.modelRarity%s*==%s*'([%w_]+)'$")
    if rarity_exact then
        local current = (string.lower(TrimString(pet.modelRarity)) == string.lower(rarity_exact)) and 1 or 0
        return current, 1, current >= 1
    end

    local all_need_floor = metric:match("^all_needs_streak_minutes_at_(%d+)$")
    if all_need_floor then
        local floor_value = tonumber(all_need_floor) or 0
        local key = "all_" .. tostring(floor_value)
        local state = EnsureAchievementState(addon)
        local current = math.floor(tonumber((state.streak_ticks or {})[key]) or 0)
        local target = def.threshold_ticks or STREAK_DEFAULT_TICKS[key] or 1
        return current, target, current >= target
    end

    local need_name, need_floor = metric:match("^need_streak_minutes_([%w_]+)_at_(%d+)$")
    if need_name and need_floor then
        local key = string.lower(need_name) .. "_" .. tostring(need_floor)
        local state = EnsureAchievementState(addon)
        local current = math.floor(tonumber((state.streak_ticks or {})[key]) or 0)
        local target = def.threshold_ticks or STREAK_DEFAULT_TICKS[key] or 1
        return current, target, current >= target
    end

    local action_key = metric:match("^stats%.actions%.([%w_]+)$")
    if action_key then
        local current = tonumber(((stats.actions or {})[action_key])) or 0
        local target = threshold_num or 1
        return current, target, current >= target
    end

    local scavenge_key = metric:match("^stats%.scavenge%.([%w_]+)$")
    if scavenge_key then
        local current = tonumber(((stats.scavenge or {})[scavenge_key])) or 0
        local target = threshold_num or 1
        return current, target, current >= target
    end

    local challenge_key = metric:match("^stats%.challenges%.([%w_]+)$")
    if challenge_key then
        local current = tonumber(((stats.challenges or {})[challenge_key])) or 0
        local target = threshold_num or 1
        return current, target, current >= target
    end

    local purchase_key = metric:match("^stats%.purchasesByKey%['([^']+)'%]$")
    if purchase_key then
        local current = tonumber(((stats.purchasesByKey or {})[purchase_key])) or 0
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "stats.actions.feed + stats.actions.play + stats.actions.rest + stats.actions.clean" then
        local actions = stats.actions or {}
        local current = (tonumber(actions.feed) or 0) + (tonumber(actions.play) or 0) + (tonumber(actions.rest) or 0) + (tonumber(actions.clean) or 0)
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "economy.challenges.completed" then
        local current = tonumber((economy.challenges or {}).completed) or 0
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "economy.shellsEarned" then
        local current = tonumber(economy.shellsEarned) or 0
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "economy.shellsSpent" then
        local current = tonumber(economy.shellsSpent) or 0
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "economy.shellsEarned - economy.shellsSpent" then
        local current = (tonumber(economy.shellsEarned) or 0) - (tonumber(economy.shellsSpent) or 0)
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "store.shells" then
        local current = tonumber(store.shells) or 0
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "economy.work.totalEarned" then
        local current = tonumber((economy.work or {}).totalEarned) or 0
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "economy.work.totalTicks" then
        local current = tonumber((economy.work or {}).totalTicks) or 0
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "stats.purchasesTotal" then
        local current = tonumber(stats.purchasesTotal) or 0
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "stats.lossesTotal" then
        local current = tonumber(stats.lossesTotal) or 0
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "sum(stats.purchasesByKey[k], category(k)=='play')" then
        local current = SumByCategory(data, stats.purchasesByKey, "play")
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "sum(stats.purchasesByKey[k], category(k)=='rest')" then
        local current = SumByCategory(data, stats.purchasesByKey, "rest")
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "sum(stats.purchasesByKey[k], category(k)=='clean')" then
        local current = SumByCategory(data, stats.purchasesByKey, "clean")
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "sum(stats.purchasesByKey[k], category(k)=='trinket')" then
        local current = SumByCategory(data, stats.purchasesByKey, "trinket")
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "sum(stats.purchasesByKey[k], category(k)=='habitat' and not habitat_bg_)" then
        local current = 0
        for key, value in pairs(stats.purchasesByKey or {}) do
            if GetItemCategory(data, key) == "habitat" and not tostring(key):match("^habitat_bg_") then
                current = current + (tonumber(value) or 0)
            end
        end
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "sum(stats.purchasesByKey[k], k in FOOD_ITEMS)" then
        local current = SumFoodValues(data, stats.purchasesByKey)
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "sum(stats.lossesByKey[k], category(k)=='play')" then
        local current = SumByCategory(data, stats.lossesByKey, "play")
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "sum(stats.lossesByKey[k], category(k)=='rest')" then
        local current = SumByCategory(data, stats.lossesByKey, "rest")
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "sum(stats.lossesByKey[k], category(k)=='clean')" then
        local current = SumByCategory(data, stats.lossesByKey, "clean")
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "sum(stats.lossesByKey[k], k in FOOD_ITEMS)" then
        local current = SumFoodValues(data, stats.lossesByKey)
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "count(keys with purchasesByKey[k]>0, category(k)=='play')" then
        local current = CountOwnedKeysByCategory(data, stats.purchasesByKey, "play")
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "count(keys with purchasesByKey[k]>0, category(k)=='rest')" then
        local current = CountOwnedKeysByCategory(data, stats.purchasesByKey, "rest")
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "count(keys with purchasesByKey[k]>0, category(k)=='clean')" then
        local current = CountOwnedKeysByCategory(data, stats.purchasesByKey, "clean")
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "count(keys with purchasesByKey[k]>0, k in FOOD_ITEMS)" then
        local current = CountOwnedFoodKeys(data, stats.purchasesByKey)
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "count_owned(category='habitat')" then
        local current = CountOwnedStoreEntries(addon, "habitat")
        local historical = CountOwnedKeysByCategory(data, stats.purchasesByKey, "habitat")
        current = math.max(current, historical)
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "count_owned(category='trinket')" then
        local current = CountOwnedStoreEntries(addon, "trinket")
        local historical = CountOwnedKeysByCategory(data, stats.purchasesByKey, "trinket")
        current = math.max(current, historical)
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "count_true(customization.habitatOwned)" then
        local current = CountTrueValues(data.customization and data.customization.habitatOwned, false)
        local historical = 1 + CountOwnedKeysByCategory(data, stats.purchasesByKey, "habitat_bg")
        current = math.max(current, historical)
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "count_true(customization.habitatOwned where key!='auto')" then
        local current = CountTrueValues(data.customization and data.customization.habitatOwned, true)
        local historical = CountOwnedKeysByCategory(data, stats.purchasesByKey, "habitat_bg")
        current = math.max(current, historical)
        local target = threshold_num or 1
        return current, target, current >= target
    end

    if metric == "min(feed,play,rest,clean)" then
        local actions = stats.actions or {}
        local current = math.min(
            tonumber(actions.feed) or 0,
            tonumber(actions.play) or 0,
            tonumber(actions.rest) or 0,
            tonumber(actions.clean) or 0
        )
        local target = threshold_num or 1
        return current, target, current >= target
    end

    local target = threshold_num or 1
    return 0, target, false
end

function MM:GetAchievementCatalog()
    BuildCatalog()
    return catalog_list
end

function MM:GetAchievementDefinition(achievementID)
    BuildCatalog()
    local id = TrimString(achievementID)
    if id == "" then
        return nil
    end
    return catalog_by_id[id]
end

function MM:GetAchievementCategories()
    BuildCatalog()
    return category_list
end

function MM:GetAchievementCollapsedCategories()
    local ui = EnsureAchievementUIState(self)
    return ui.achievementCollapsedCategories
end

function MM:IsAchievementCategoryCollapsed(categoryKey)
    local key = TrimString(categoryKey)
    if key == "" then
        return false
    end
    local collapsed = self:GetAchievementCollapsedCategories()
    return collapsed[key] == true
end

function MM:SetAchievementCategoryCollapsed(categoryKey, collapsed)
    local key = TrimString(categoryKey)
    if key == "" then
        return
    end
    local categories = self:GetAchievementCollapsedCategories()
    if collapsed then
        categories[key] = true
    else
        categories[key] = nil
    end
end

function MM:ToggleAchievementCategoryCollapsed(categoryKey)
    local key = TrimString(categoryKey)
    if key == "" then
        return false
    end
    local nextState = not self:IsAchievementCategoryCollapsed(key)
    self:SetAchievementCategoryCollapsed(key, nextState)
    return nextState
end

function MM:GetAchievementFilterRows()
    BuildCatalog()
    local collapsedCategories = self:GetAchievementCollapsedCategories()
    local summaryCollapsed = collapsedCategories["__summary"] == true

    local rows = {
        {
            key = "all",
            label = "Summary",
            indent = 0,
            isCategory = true,
            categoryKey = "__summary",
            collapsed = summaryCollapsed,
            hidden = false,
        },
        {
            key = "almost",
            label = "Almost Completed",
            indent = 1,
            isCategory = false,
            categoryKey = "__summary",
            hidden = summaryCollapsed,
        },
    }

    for _, cat in ipairs(category_list) do
        local isCollapsed = collapsedCategories[cat.key] == true
        rows[#rows + 1] = {
            key = "cat:" .. cat.key,
            label = cat.label,
            indent = 0,
            isCategory = true,
            categoryKey = cat.key,
            collapsed = isCollapsed,
            hidden = false,
        }
        for _, sub in ipairs(cat.subcategories or {}) do
            rows[#rows + 1] = {
                key = "sub:" .. cat.key .. "|" .. sub.key,
                label = sub.label,
                indent = 1,
                isCategory = false,
                categoryKey = cat.key,
                hidden = isCollapsed,
            }
        end
    end

    return rows
end

function MM:GetAchievementsForFilter(filterKey)
    BuildCatalog()

    local key = TrimString(filterKey)
    if key == "" or key == "all" then
        local state = EnsureAchievementState(self)
        local unlocked_rows = {}
        local locked_rows = {}
        for _, def in ipairs(catalog_list) do
            local unlocked_entry = state.unlocked[def.id]
            if type(unlocked_entry) == "table" then
                unlocked_rows[#unlocked_rows + 1] = {
                    def = def,
                    entry = unlocked_entry,
                }
            else
                locked_rows[#locked_rows + 1] = def
            end
        end

        table.sort(unlocked_rows, function(a, b)
            local seq_a = tonumber(a.entry.unlockSequence) or 0
            local seq_b = tonumber(b.entry.unlockSequence) or 0
            if seq_a ~= seq_b then
                return seq_a > seq_b
            end
            local unlocked_a = tonumber(a.entry.unlockedAt) or 0
            local unlocked_b = tonumber(b.entry.unlockedAt) or 0
            if unlocked_a ~= unlocked_b then
                return unlocked_a > unlocked_b
            end
            return tostring(a.def.id) < tostring(b.def.id)
        end)

        local ordered = {}
        for _, row in ipairs(unlocked_rows) do
            ordered[#ordered + 1] = row.def
        end
        for _, def in ipairs(locked_rows) do
            ordered[#ordered + 1] = def
        end
        return ordered
    end

    if key == "almost" then
        local ranked = {}
        for _, def in ipairs(catalog_list) do
            if not self:IsAchievementUnlocked(def.id) then
                local progress = self:GetAchievementProgress(def, { source = "achievements_filter_almost" }) or {}
                if not progress.complete then
                    local target = tonumber(progress.target) or 1
                    if target <= 0 then
                        target = 1
                    end
                    local current = tonumber(progress.current) or 0
                    if current < 0 then
                        current = 0
                    end
                    local ratio = current / target
                    if ratio < 0 then
                        ratio = 0
                    elseif ratio > 1 then
                        ratio = 1
                    end
                    local remaining = math.max(0, target - current)
                    ranked[#ranked + 1] = {
                        def = def,
                        ratio = ratio,
                        remaining = remaining,
                        target = target,
                    }
                end
            end
        end

        table.sort(ranked, function(a, b)
            if a.ratio ~= b.ratio then
                return a.ratio > b.ratio
            end
            local a_norm_remaining = a.remaining / math.max(1, a.target)
            local b_norm_remaining = b.remaining / math.max(1, b.target)
            if a_norm_remaining ~= b_norm_remaining then
                return a_norm_remaining < b_norm_remaining
            end
            if a.remaining ~= b.remaining then
                return a.remaining < b.remaining
            end
            return tostring(a.def.id) < tostring(b.def.id)
        end)

        local ordered = {}
        for _, row in ipairs(ranked) do
            ordered[#ordered + 1] = row.def
        end
        return ordered
    end

    local out = {}
    local category_filter = key:match("^cat:(.+)$")
    if category_filter then
        for _, def in ipairs(catalog_list) do
            if def.category == category_filter then
                out[#out + 1] = def
            end
        end
        return out
    end

    local cat, sub = key:match("^sub:(.-)|(.-)$")
    if cat and sub then
        for _, def in ipairs(catalog_list) do
            if def.category == cat and def.subcategory == sub then
                out[#out + 1] = def
            end
        end
        return out
    end

    return catalog_list
end

function MM:GetAchievementState()
    return EnsureAchievementState(self)
end

function MM:IsAchievementUnlocked(achievementID)
    local id = TrimString(achievementID)
    if id == "" then
        return false
    end
    local state = EnsureAchievementState(self)
    return state.unlocked[id] ~= nil
end

function MM:GetAchievementTitle(achievementID)
    local def = self:GetAchievementDefinition(achievementID)
    return (def and def.name) or TrimString(achievementID)
end

function MM:GetAchievementPointsForTier(tier)
    local t = math.floor(tonumber(tier) or 1)
    if t < 1 then t = 1 end
    if t > 5 then t = 5 end
    local source = (ns and ns.AchievementCatalog and ns.AchievementCatalog.points_by_tier) or DEFAULT_POINTS_BY_TIER
    return math.floor(tonumber(source[t]) or DEFAULT_POINTS_BY_TIER[t] or 5)
end

function MM:GetAchievementPoints()
    local state = EnsureAchievementState(self)
    local points = math.floor(tonumber(state.points) or 0)

    if points <= 0 and next(state.unlocked or {}) ~= nil then
        points = 0
        for id, entry in pairs(state.unlocked) do
            local def = self:GetAchievementDefinition(id)
            if def then
                points = points + (tonumber(entry.points) or tonumber(def.points) or self:GetAchievementPointsForTier(def.tier))
            else
                points = points + (tonumber(entry.points) or 0)
            end
        end
        state.points = points
    end

    return points
end

function MM:GetAchievementTotalPossiblePoints()
    BuildCatalog()
    return total_possible_points
end

function MM:GetAchievementIcon(definition)
    local def = definition
    if type(definition) == "string" then
        def = self:GetAchievementDefinition(definition)
    end
    if not def then
        return "Interface\\Icons\\INV_Misc_QuestionMark"
    end

    local sub_key = string.format("%s:%s", def.category or "", def.subcategory or "")
    return SUBCATEGORY_ICONS[sub_key]
        or CATEGORY_DEFAULT_ICONS[def.category or ""]
        or "Interface\\Icons\\INV_Misc_QuestionMark"
end

function MM:GetAchievementProgress(definition, context)
    local def = definition
    if type(definition) == "string" then
        def = self:GetAchievementDefinition(definition)
    end
    if not def then
        return {
            complete = false,
            current = 0,
            target = 1,
            percent = 0,
            text = "0 / 1",
        }
    end

    local data = GetMetricData(self)
    local current, target, complete = EvaluateMetric(self, def, context or {}, data)
    current = tonumber(current) or 0
    target = tonumber(target) or 1
    if target <= 0 then
        target = 1
    end

    local unlocked = self:IsAchievementUnlocked(def.id)
    if unlocked then
        complete = true
        current = target
    elseif complete and current > target then
        -- Keep completed cards visually capped at their threshold.
        current = target
    end

    local percent = self:Clamp((current / target) * 100, 0, 100)
    local text
    if complete and target <= 1 then
        text = "Complete"
    elseif def.metric:find("streak_minutes", 1, true) then
        text = string.format("%s / %s", FormatTicksAsTime(current), FormatTicksAsTime(target))
    elseif target <= 1 and not def.metric:find("stats", 1, true) and not def.metric:find("economy", 1, true) and not def.metric:find("store", 1, true) then
        text = complete and "Complete" or "Incomplete"
    else
        text = string.format("%d / %d", math.floor(current + 0.5), math.floor(target + 0.5))
    end

    return {
        complete = complete,
        unlocked = unlocked,
        current = current,
        target = target,
        percent = percent,
        text = text,
    }
end

function MM:UnlockAchievement(achievementID, context)
    local def = self:GetAchievementDefinition(achievementID)
    if not def then
        return false
    end

    local state = EnsureAchievementState(self)
    if state.unlocked[def.id] ~= nil then
        return false
    end

    state.unlockSequenceCounter = math.floor(tonumber(state.unlockSequenceCounter) or 0) + 1
    local points_awarded = math.floor(tonumber(def.points) or self:GetAchievementPointsForTier(def.tier))
    local unlocked_entry = {
        id = def.id,
        title = def.name,
        unlockedAt = time() or 0,
        unlockSequence = state.unlockSequenceCounter,
        points = points_awarded,
        tier = def.tier,
        context = context,
    }

    state.unlocked[def.id] = unlocked_entry
    state.points = math.floor((tonumber(state.points) or 0) + points_awarded)
    state.recent_unlocks[#state.recent_unlocks + 1] = def.id
    while #state.recent_unlocks > 50 do
        table.remove(state.recent_unlocks, 1)
    end

    if self.QueueAchievementToast then
        self:QueueAchievementToast(def, points_awarded)
    end

    if self.PushActivity then
        self:PushActivity(
            string.format("Achievement %s earned! (+%d)", def.name, points_awarded),
            { source = "achievement", tone = "achievement", skipAchievementCheck = true }
        )
    end

    return true
end

function MM:UpdateAchievementStreaksOnTick()
    local state = EnsureAchievementState(self)
    local streak = state.streak_ticks
    local pet = (self.GetPet and self:GetPet()) or {}

    if pet.stage ~= "adolescent" then
        streak.all_250 = 0
        streak.all_500 = 0
        streak.all_750 = 0
        streak.hunger_950 = 0
        streak.joy_950 = 0
        streak.energy_950 = 0
        streak.cleanliness_950 = 0
        return
    end

    local hunger = tonumber(pet.hunger) or 0
    local joy = tonumber(pet.joy) or 0
    local energy = tonumber(pet.energy) or 0
    local clean = tonumber(pet.cleanliness) or 0

    local function update(key, condition)
        if condition then
            streak[key] = math.floor((tonumber(streak[key]) or 0) + 1)
        else
            streak[key] = 0
        end
    end

    update("all_250", hunger >= 250 and joy >= 250 and energy >= 250 and clean >= 250)
    update("all_500", hunger >= 500 and joy >= 500 and energy >= 500 and clean >= 500)
    update("all_750", hunger >= 750 and joy >= 750 and energy >= 750 and clean >= 750)
    update("hunger_950", hunger >= 950)
    update("joy_950", joy >= 950)
    update("energy_950", energy >= 950)
    update("cleanliness_950", clean >= 950)
end

function MM:EvaluateAchievements(context)
    BuildCatalog()
    if #catalog_list == 0 then
        return 0
    end

    if self._mmAchievementEvalBusy then
        return 0
    end

    self._mmAchievementEvalBusy = true

    local unlocked_count = 0
    local eval_context = context or {}
    local data = GetMetricData(self)

    for _, def in ipairs(catalog_list) do
        if not self:IsAchievementUnlocked(def.id) then
            local current, target, complete = EvaluateMetric(self, def, eval_context, data)
            if complete then
                if self:UnlockAchievement(def.id, {
                    trigger = eval_context.source or "runtime",
                    metric = def.metric,
                    current = tonumber(current) or 0,
                    target = tonumber(target) or 0,
                }) then
                    unlocked_count = unlocked_count + 1
                end
            end
        end
    end

    self._mmAchievementEvalBusy = false
    return unlocked_count
end

function MM:EvaluateTraitPairAchievements()
    return (self:EvaluateAchievements({ source = "trait_pair_check" }) or 0) > 0
end

function MM:HandleAchievementActivityTrigger(message, resolvedMeta)
    self:EvaluateAchievements({
        source = "activity",
        message = message,
        meta = resolvedMeta,
    })
end
