local _, ns = ...
local L = ns.L or {}

local CompendiumView = {}
ns.UICompendiumView = CompendiumView

local BACKDROP_TEMPLATE = BackdropTemplateMixin and "BackdropTemplate" or nil

local UIData = ns.UIData or {}
local DATA = ns.Data and ns.Data.MidnightCompendium or {}
local ENTRIES = type(DATA.entries) == "table" and DATA.entries or {}
local THEME = UIData.THEME or {}
local PREMIUM = UIData.PREMIUM or {}
local STATUS_COLORS = UIData.STATUS_COLORS or {}

local GOLD = THEME.gold or { 0.92, 0.76, 0.24 }
local GOLD_DIM = THEME.goldDim or { 0.40, 0.31, 0.10 }
local PARCHMENT = THEME.parchment or { 0.93, 0.89, 0.77 }
local METAL = THEME.metalDeep or { 0.07, 0.06, 0.055 }
local SHADOW = THEME.shadow or { 0.01, 0.01, 0.01 }

local SIDEBAR_WIDTH = 188
local ROW_HEIGHT = 36
local ROW_GAP = 5
local ROW_STEP = ROW_HEIGHT + ROW_GAP
local ROW_COLUMN_GAP = 8
local MAX_CATEGORY_BUTTONS = 6
local CACHE_SECONDS = 5
local DECORATION_SNAPSHOT_DB_KEY = "compendiumDecorationCounts"
local HOUSING_CATALOG_ADDONS = {
    "Blizzard_HousingDashboard",
    "Blizzard_HousingHouseSettings",
    "Blizzard_HousingModelPreview",
}
local ICON_BORDER = PREMIUM.compendiumIconBorder or "Interface\\AddOns\\Vaultloom\\Assets\\compendium_icon_border.tga"
local WAYPOINT_ICON = PREMIUM.compendiumWaypointIcon or "Interface\\AddOns\\Vaultloom\\Assets\\compendium_waypoint_icon.tga"
local ROUND_MASK = "Interface\\CharacterFrame\\TempPortraitAlphaMask"

local CATEGORY_ORDER = {
    "all",
    "mounts",
    "pets",
    "toys",
    "decorations",
    "recipes",
}

local CATEGORY_ICONS = {
    all = "Interface\\Icons\\INV_Misc_Book_09",
    mounts = "Interface\\Icons\\Ability_Mount_RidingHorse",
    pets = "Interface\\Icons\\INV_Box_PetCarrier_01",
    toys = "Interface\\Icons\\INV_Misc_Toy_10",
    decorations = PREMIUM.compendiumDecorationIcon or "Interface\\Icons\\INV_Misc_Statue_02",
    recipes = "Interface\\Icons\\INV_Scroll_03",
}

local SOURCE_ORDER = {
    "renown",
    "reputation",
    "drop",
    "achievement",
    "quest",
    "treasure",
    "delve",
    "prey",
    "promotion",
    "dungeon",
    "raid",
    "pvp",
    "worldevent",
    "event",
    "profession",
    "trainer",
    "vendor",
    "discovery",
    "specialization",
    "crafted",
    "tradingpost",
    "prepatch",
    "eversong",
    "zulaman",
    "harandar",
    "voidstorm",
    "wild",
}

local RECIPE_SOURCE_ORDER = {
    trainer = 1,
    vendor = 2,
    discovery = 3,
    specialization = 4,
    profession = 5,
    drop = 6,
    crafted = 7,
    treasure = 8,
    quest = 9,
    dungeon = 10,
    raid = 11,
}

local CATEGORY_RANK = {}
for index, key in ipairs(CATEGORY_ORDER) do
    CATEGORY_RANK[key] = index
end

local SOURCE_RANK = {}
for index, key in ipairs(SOURCE_ORDER) do
    SOURCE_RANK[key] = index
end

local ITEM_ICON_FALLBACKS = {
    [268481] = "Interface\\Icons\\INV_Cooking_80_ChoralHoney3",
    [269828] = "Interface\\Icons\\INV_Weapon_Shortblade_28",
    [269829] = "Interface\\Icons\\INV_BabyAmaniEagle_Burgundy",
    [275436] = "Interface\\Icons\\INV_Misc_Noose_01",
}

local PROFESSION_RANK = {}
local PROFESSION_KEY_BY_SKILL_LINE = {}
for index, prof in ipairs(DATA.professionOrder or {}) do
    if prof and prof.key then
        PROFESSION_RANK[prof.key] = index
        if prof.skillLine then
            PROFESSION_KEY_BY_SKILL_LINE[tonumber(prof.skillLine)] = prof.key
        end
    end
end

local SOURCE_LABEL_KEYS = {
    achievement = "COMPENDIUM_SOURCE_ACHIEVEMENT",
    crafted = "COMPENDIUM_SOURCE_CRAFTED",
    delve = "COMPENDIUM_SOURCE_DELVE",
    discovery = "COMPENDIUM_SOURCE_DISCOVERY",
    drop = "COMPENDIUM_SOURCE_DROP",
    dungeon = "COMPENDIUM_SOURCE_DUNGEON",
    event = "COMPENDIUM_SOURCE_EVENT",
    eversong = "COMPENDIUM_SOURCE_EVERSONG",
    harandar = "COMPENDIUM_SOURCE_HARANDAR",
    prepatch = "COMPENDIUM_SOURCE_PREPATCH",
    prey = "COMPENDIUM_SOURCE_PREY",
    profession = "COMPENDIUM_SOURCE_PROFESSION",
    promotion = "COMPENDIUM_SOURCE_PROMOTION",
    pvp = "COMPENDIUM_SOURCE_PVP",
    quest = "COMPENDIUM_SOURCE_QUEST",
    raid = "COMPENDIUM_SOURCE_RAID",
    renown = "COMPENDIUM_SOURCE_RENOWN",
    reputation = "COMPENDIUM_SOURCE_REPUTATION",
    specialization = "COMPENDIUM_SOURCE_SPECIALIZATION",
    trainer = "COMPENDIUM_SOURCE_TRAINER",
    tradingpost = "COMPENDIUM_SOURCE_TRADING_POST",
    treasure = "COMPENDIUM_SOURCE_TREASURE",
    vendor = "COMPENDIUM_SOURCE_VENDOR",
    voidstorm = "COMPENDIUM_SOURCE_VOIDSTORM",
    wild = "COMPENDIUM_SOURCE_WILD",
    worldevent = "COMPENDIUM_SOURCE_WORLD_EVENT",
    zulaman = "COMPENDIUM_SOURCE_ZULAMAN",
}

local ZONE_MAP_IDS = {
    ["Broken Throne Ritual Site"] = 2437,
    ["Daggerspine Point Ritual Site"] = 2395,
    ["Eversong Woods"] = 2395,
    ["Harandar"] = 2413,
    ["Isle of Quel'Danas"] = 2424,
    ["Silvermoon City"] = 2393,
    ["Schattenhochland"] = 241,
    ["Twilight Highlands"] = 241,
    ["Voidstorm"] = 2405,
    ["Zul'Aman"] = 2437,
}

local runtimeCache = {}
local activeProfessionCache
local housingCatalogLoadAttempted = false
local housingCatalogObservedOwnedCount = false

local function callHelper(helpers, key, ...)
    local fn = helpers and helpers[key]
    if type(fn) == "function" then
        return fn(...)
    end
    return nil
end

local function createLabel(parent, fontObject, justify)
    local text = parent:CreateFontString(nil, "OVERLAY", fontObject)
    text:SetJustifyH(justify or "LEFT")
    text:SetJustifyV("MIDDLE")
    return text
end

local function createSectionFrame(parent, helpers, variant)
    return callHelper(helpers, "createSectionFrame", parent, variant) or CreateFrame("Frame", nil, parent, BACKDROP_TEMPLATE)
end

local function createMiniActionButton(parent, helpers, text, width)
    return callHelper(helpers, "createMiniActionButton", parent, text, width) or CreateFrame("Button", nil, parent, BACKDROP_TEMPLATE)
end

local function setMiniActionButtonActive(button, active, helpers)
    if helpers and type(helpers.setMiniActionButtonActive) == "function" then
        helpers.setMiniActionButtonActive(button, active)
    elseif button then
        button:SetAlpha(active and 1 or 0.82)
    end
end

local function setTextureColor(texture, color, alpha)
    if not texture or type(color) ~= "table" then
        return
    end
    texture:SetVertexColor(color[1], color[2], color[3], alpha == nil and 1 or alpha)
end

local function clamp(value, minimum, maximum)
    value = tonumber(value) or 0
    if value < minimum then
        return minimum
    end
    if value > maximum then
        return maximum
    end
    return value
end

local function getNow()
    return type(time) == "function" and time() or 0
end

local function tryLoadHousingCatalogAddOns()
    if housingCatalogLoadAttempted then
        return
    end
    housingCatalogLoadAttempted = true

    for _, addonName in ipairs(HOUSING_CATALOG_ADDONS) do
        if C_AddOns and type(C_AddOns.LoadAddOn) == "function" then
            pcall(C_AddOns.LoadAddOn, addonName)
        elseif type(LoadAddOn) == "function" then
            pcall(LoadAddOn, addonName)
        end
    end
end

local function normalizeSearch(text)
    text = tostring(text or ""):lower()
    text = text:gsub("^%s+", ""):gsub("%s+$", "")
    return text
end

local function prettifyKey(key)
    key = tostring(key or "")
    key = key:gsub("_", " "):gsub("-", " ")
    return key:gsub("^%l", string.upper)
end

local function getCategoryLabel(categoryKey)
    if categoryKey == "all" then
        return L.COMPENDIUM_CATEGORY_ALL or "All"
    elseif categoryKey == "mounts" then
        return L.COMPENDIUM_CATEGORY_MOUNTS or "Mounts"
    elseif categoryKey == "pets" then
        return L.COMPENDIUM_CATEGORY_PETS or "Pets"
    elseif categoryKey == "toys" then
        return L.COMPENDIUM_CATEGORY_TOYS or "Toys"
    elseif categoryKey == "decorations" then
        return L.COMPENDIUM_CATEGORY_DECORATIONS or "Decorations"
    elseif categoryKey == "recipes" then
        return L.COMPENDIUM_CATEGORY_RECIPES or "Recipes"
    end
    return prettifyKey(categoryKey)
end

local function getSourceLabel(sourceKey)
    local labelKey = SOURCE_LABEL_KEYS[tostring(sourceKey or "")]
    if labelKey and L[labelKey] then
        return L[labelKey]
    end
    return prettifyKey(sourceKey)
end

local function getLocalizedText(value, localeKey)
    if type(localeKey) == "string" and localeKey ~= "" then
        local localized = L[localeKey]
        if type(localized) == "string" and localized ~= "" then
            return localized
        end
    end
    if type(value) == "string" and value ~= "" then
        return value
    end
    return nil
end

local function getProfessionLabel(professionKey, fallback)
    local key = tostring(professionKey or "")
    if key == "alchemy" then
        return L.COMPENDIUM_PROFESSION_ALCHEMY or fallback or "Alchemy"
    elseif key == "blacksmithing" then
        return L.COMPENDIUM_PROFESSION_BLACKSMITHING or fallback or "Blacksmithing"
    elseif key == "cooking" then
        return L.COMPENDIUM_PROFESSION_COOKING or fallback or "Cooking"
    elseif key == "enchanting" then
        return L.COMPENDIUM_PROFESSION_ENCHANTING or fallback or "Enchanting"
    elseif key == "engineering" then
        return L.COMPENDIUM_PROFESSION_ENGINEERING or fallback or "Engineering"
    elseif key == "inscription" then
        return L.COMPENDIUM_PROFESSION_INSCRIPTION or fallback or "Inscription"
    elseif key == "jewelcrafting" then
        return L.COMPENDIUM_PROFESSION_JEWELCRAFTING or fallback or "Jewelcrafting"
    elseif key == "leatherworking" then
        return L.COMPENDIUM_PROFESSION_LEATHERWORKING or fallback or "Leatherworking"
    elseif key == "tailoring" then
        return L.COMPENDIUM_PROFESSION_TAILORING or fallback or "Tailoring"
    end
    return fallback or prettifyKey(key)
end

local function getStatusColor(statusKey)
    if statusKey == "collected" then
        return STATUS_COLORS.complete or { 0.42, 0.68, 0.44 }
    elseif statusKey == "missing" then
        return STATUS_COLORS.missing or { 0.50, 0.56, 0.66 }
    end
    return STATUS_COLORS.unknown or { 0.78, 0.70, 0.42 }
end

local function getCategoryIcon(categoryKey)
    return CATEGORY_ICONS[categoryKey or ""] or CATEGORY_ICONS.all
end

local function ensureRoundMask(texture)
    if not texture or texture.vlRoundMask or not texture.AddMaskTexture then
        return
    end
    local parent = texture:GetParent()
    if not parent or not parent.CreateMaskTexture then
        return
    end
    local mask = parent:CreateMaskTexture()
    mask:SetTexture(ROUND_MASK, "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    mask:SetAllPoints(texture)
    texture:AddMaskTexture(mask)
    texture.vlRoundMask = mask
end

local function applyIconTexture(texture, icon, round)
    if not texture then
        return
    end
    texture:SetTexture(icon)
    texture:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    if round then
        ensureRoundMask(texture)
    end
end

local function getItemNameAndIcon(itemID)
    itemID = tonumber(itemID)
    if not itemID then
        return nil, nil
    end
    local name, icon
    if C_Item and type(C_Item.GetItemNameByID) == "function" then
        local ok, result = pcall(C_Item.GetItemNameByID, itemID)
        if ok and type(result) == "string" and result ~= "" then
            name = result
        end
    end
    if GetItemInfoInstant then
        local ok, _, _, _, _, itemIcon = pcall(GetItemInfoInstant, itemID)
        if ok and itemIcon and itemIcon ~= 0 and itemIcon ~= "" then
            icon = itemIcon
        end
    end
    return name, icon or ITEM_ICON_FALLBACKS[itemID]
end

local function getSpellNameAndIcon(spellID)
    spellID = tonumber(spellID)
    if not spellID then
        return nil, nil
    end

    if C_Spell and type(C_Spell.GetSpellInfo) == "function" then
        local ok, info = pcall(C_Spell.GetSpellInfo, spellID)
        if ok and type(info) == "table" then
            return info.name, info.iconID
        end
    end
    if C_Spell and type(C_Spell.GetSpellName) == "function" then
        local okName, name = pcall(C_Spell.GetSpellName, spellID)
        local icon
        if C_Spell.GetSpellTexture then
            local okIcon, texture = pcall(C_Spell.GetSpellTexture, spellID)
            if okIcon then
                icon = texture
            end
        end
        if okName then
            return name, icon
        end
    end
    if GetSpellInfo then
        local ok, name, _, icon = pcall(GetSpellInfo, spellID)
        if ok then
            return name, icon
        end
    end
    return nil, nil
end

local function resolveMountID(entry)
    if type(entry) ~= "table" then
        return nil
    end
    local mountID = tonumber(entry.mountID)
    if mountID then
        return mountID
    end
    if not C_MountJournal then
        return nil
    end
    local itemID = tonumber(entry.itemID)
    if itemID and type(C_MountJournal.GetMountFromItem) == "function" then
        local ok, resolvedMountID = pcall(C_MountJournal.GetMountFromItem, itemID)
        resolvedMountID = ok and tonumber(resolvedMountID) or nil
        if resolvedMountID then
            entry.mountID = resolvedMountID
            return resolvedMountID
        end
    end
    local spellID = tonumber(entry.spellID or entry.id)
    if spellID and type(C_MountJournal.GetMountFromSpell) == "function" then
        local ok, resolvedMountID = pcall(C_MountJournal.GetMountFromSpell, spellID)
        resolvedMountID = ok and tonumber(resolvedMountID) or nil
        if resolvedMountID then
            entry.mountID = resolvedMountID
            return resolvedMountID
        end
    end
    return nil
end

local function getCurrentCharacterRecord(createIfMissing)
    if ns.GetCurrentCharacterRecord then
        return ns:GetCurrentCharacterRecord(createIfMissing == true)
    end
    if ns.GetStoredCharacterRecord then
        return ns:GetStoredCharacterRecord(nil, createIfMissing == true)
    end
    if not ns.GetCurrentCharacterInfo then
        return nil
    end

    ns.db = type(ns.db) == "table" and ns.db or {}
    ns.db.characters = type(ns.db.characters) == "table" and ns.db.characters or {}
    local currentInfo = ns:GetCurrentCharacterInfo()
    local key = currentInfo and currentInfo.key
    if type(key) ~= "string" or key == "" then
        return nil
    end
    if createIfMissing and type(ns.db.characters[key]) ~= "table" then
        ns.db.characters[key] = {}
    end
    return ns.db.characters[key]
end

local function getDecorationSnapshotStore(createIfMissing)
    local record = getCurrentCharacterRecord(createIfMissing == true)
    if type(record) ~= "table" then
        return nil
    end
    if createIfMissing and type(record[DECORATION_SNAPSHOT_DB_KEY]) ~= "table" then
        record[DECORATION_SNAPSHOT_DB_KEY] = {}
    end
    return record[DECORATION_SNAPSHOT_DB_KEY]
end

local function getDecorationSnapshotKeys(entry)
    local keys = {}
    local decorID = tonumber(entry and entry.decorID)
    local itemID = tonumber(entry and entry.itemID)
    if decorID then
        keys[#keys + 1] = "decor:" .. decorID
    end
    if itemID then
        keys[#keys + 1] = "item:" .. itemID
    end
    if type(entry and entry.key) == "string" and entry.key ~= "" then
        keys[#keys + 1] = "entry:" .. entry.key
    end
    return keys
end

local function getDecorationSnapshot(entry)
    local store = getDecorationSnapshotStore(false)
    if type(store) ~= "table" then
        return nil
    end

    local bestCount
    for _, key in ipairs(getDecorationSnapshotKeys(entry)) do
        local value = store[key]
        local count = type(value) == "table" and tonumber(value.ownedCount) or tonumber(value)
        if count and (not bestCount or count > bestCount) then
            bestCount = count
        end
    end
    return bestCount
end

local function rememberDecorationSnapshot(entry, ownedCount)
    local count = tonumber(ownedCount)
    if not count then
        return
    end

    count = math.max(0, math.floor(count))
    if count > 0 then
        housingCatalogObservedOwnedCount = true
    elseif not housingCatalogObservedOwnedCount then
        return
    end

    local store = getDecorationSnapshotStore(true)
    if type(store) ~= "table" then
        return
    end

    local snapshot = {
        ownedCount = count,
        updatedAt = getNow(),
    }
    for _, key in ipairs(getDecorationSnapshotKeys(entry)) do
        store[key] = snapshot
    end
end

local function getHousingOwnedCount(info)
    if type(info) ~= "table" then
        return nil
    end
    if info.numPlaced == nil and info.quantity == nil and info.remainingRedeemable == nil then
        return nil
    end
    return (tonumber(info.numPlaced) or 0)
        + (tonumber(info.quantity) or 0)
        + (tonumber(info.remainingRedeemable) or 0)
end

local function getDecorationInfo(entry)
    local name, icon, collected, ownedCount
    local decorID = tonumber(entry.decorID)
    local itemID = tonumber(entry.itemID)

    local function applyCatalogInfo(info)
        if type(info) ~= "table" then
            return false
        end
        local apiOwnedCount = getHousingOwnedCount(info)
        local snapshotCount = getDecorationSnapshot(entry)
        if apiOwnedCount ~= nil then
            if apiOwnedCount <= 0 and snapshotCount and snapshotCount > 0 and not housingCatalogObservedOwnedCount then
                ownedCount = snapshotCount
            elseif apiOwnedCount <= 0 and not housingCatalogObservedOwnedCount then
                ownedCount = nil
            else
                ownedCount = apiOwnedCount
                rememberDecorationSnapshot(entry, apiOwnedCount)
            end
            if ownedCount ~= nil then
                collected = ownedCount > 0
            end
        elseif snapshotCount ~= nil then
            ownedCount = snapshotCount
            collected = ownedCount > 0
        end
        if info.name and info.name ~= "" then
            name = info.name
        end
        if info.iconTexture and info.iconTexture ~= 0 then
            icon = icon or info.iconTexture
        end
        return true
    end

    if decorID and C_HousingDecor and type(C_HousingDecor.GetDecorName) == "function" then
        local ok, result = pcall(C_HousingDecor.GetDecorName, decorID)
        if ok and type(result) == "string" and result ~= "" then
            name = result
        end
    end
    if decorID and C_HousingDecor and type(C_HousingDecor.GetDecorIcon) == "function" then
        local ok, result = pcall(C_HousingDecor.GetDecorIcon, decorID)
        if ok and result and result ~= 0 then
            icon = result
        end
    end
    if decorID and C_HousingCatalog and type(C_HousingCatalog.GetCatalogEntryInfoByRecordID) == "function" then
        local entryType = Enum and Enum.HousingCatalogEntryType and Enum.HousingCatalogEntryType.Decor or 1
        local ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfoByRecordID, entryType, decorID, true)
        if ok then
            applyCatalogInfo(info)
        end
    end
    if ownedCount == nil and itemID and C_HousingCatalog and type(C_HousingCatalog.GetCatalogEntryInfoByItem) == "function" then
        local ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfoByItem, itemID, true)
        if ok then
            applyCatalogInfo(info)
        end
    end
    if ownedCount == nil then
        local snapshotCount = getDecorationSnapshot(entry)
        if snapshotCount ~= nil then
            ownedCount = snapshotCount
            collected = ownedCount > 0
        end
    end
    if not name or not icon then
        local itemName, itemIcon = getItemNameAndIcon(itemID)
        name = name or itemName
        icon = icon or itemIcon
    end

    return name, icon, collected, ownedCount
end

local function getRuntimeInfo(entry)
    local key = entry and entry.key
    if not key then
        return {
            name = L.UNKNOWN or "Unknown",
            icon = CATEGORY_ICONS.all,
            statusKey = "unknown",
            statusLabel = L.COMPENDIUM_STATUS_UNKNOWN or "Unknown",
        }
    end

    local now = getNow()
    local cached = runtimeCache[key]
    if cached and (cached.expiresAt or 0) >= now then
        return cached
    end

    local name = entry.name or L.UNKNOWN or "Unknown"
    local icon = getCategoryIcon(entry.category)
    local statusKey = "unknown"
    local statusLabel = L.COMPENDIUM_STATUS_UNKNOWN or "Unknown"
    local collected
    local decorOwnedCount

    if entry.category == "mounts" then
        local mountID = resolveMountID(entry)
        if C_MountJournal and type(C_MountJournal.GetMountInfoByID) == "function" and mountID then
            local ok, mountName, _, mountIcon, _, _, _, _, _, _, _, isCollected = pcall(C_MountJournal.GetMountInfoByID, mountID)
            if ok then
                if type(mountName) == "string" and mountName ~= "" then
                    name = mountName
                end
                if mountIcon and mountIcon ~= 0 then
                    icon = mountIcon
                end
                collected = isCollected == true
            end
        end
        if tonumber(entry.itemID) and (not icon or icon == getCategoryIcon(entry.category)) then
            local itemName, itemIcon = getItemNameAndIcon(entry.itemID)
            name = itemName or name
            icon = itemIcon or icon
        elseif tonumber(entry.spellID or entry.id) and (not icon or icon == getCategoryIcon(entry.category)) then
            local spellName, spellIcon = getSpellNameAndIcon(entry.spellID or entry.id)
            name = spellName or name
            icon = spellIcon or icon
        end
        statusLabel = collected and (L.COMPENDIUM_STATUS_COLLECTED or "Collected") or (L.COMPENDIUM_STATUS_MISSING or "Missing")
    elseif entry.category == "pets" then
        if C_PetJournal and type(C_PetJournal.GetPetInfoBySpeciesID) == "function" and tonumber(entry.speciesID) then
            local ok, petName, petIcon = pcall(C_PetJournal.GetPetInfoBySpeciesID, tonumber(entry.speciesID))
            if ok then
                if type(petName) == "string" and petName ~= "" then
                    name = petName
                end
                if petIcon and petIcon ~= 0 then
                    icon = petIcon
                end
            end
        end
        if C_PetJournal and type(C_PetJournal.GetNumCollectedInfo) == "function" and tonumber(entry.speciesID) then
            local ok, count = pcall(C_PetJournal.GetNumCollectedInfo, tonumber(entry.speciesID))
            if ok then
                collected = (tonumber(count) or 0) > 0
            end
        end
        if tonumber(entry.itemID) and (not icon or icon == getCategoryIcon(entry.category)) then
            local itemName, itemIcon = getItemNameAndIcon(entry.itemID)
            name = itemName or name
            icon = itemIcon or icon
        end
        statusLabel = collected and (L.COMPENDIUM_STATUS_COLLECTED or "Collected") or (L.COMPENDIUM_STATUS_MISSING or "Missing")
    elseif entry.category == "toys" then
        local itemName, itemIcon = getItemNameAndIcon(entry.itemID)
        name = itemName or name
        icon = itemIcon or icon
        if C_ToyBox and type(C_ToyBox.GetToyInfo) == "function" and tonumber(entry.itemID) then
            local ok, _, toyName, toyIcon = pcall(C_ToyBox.GetToyInfo, tonumber(entry.itemID))
            if ok then
                if type(toyName) == "string" and toyName ~= "" then
                    name = toyName
                end
                if toyIcon and toyIcon ~= 0 then
                    icon = toyIcon
                end
            end
        end
        if PlayerHasToy and tonumber(entry.itemID) then
            local ok, hasToy = pcall(PlayerHasToy, tonumber(entry.itemID))
            if ok then
                collected = hasToy == true
            end
        end
        statusLabel = collected and (L.COMPENDIUM_STATUS_COLLECTED or "Collected") or (L.COMPENDIUM_STATUS_MISSING or "Missing")
    elseif entry.category == "decorations" then
        local decorName, decorIcon, decorCollected
        decorName, decorIcon, decorCollected, decorOwnedCount = getDecorationInfo(entry)
        name = decorName or name
        icon = decorIcon or PREMIUM.compendiumDecorationIcon or icon
        collected = decorCollected
        statusLabel = decorOwnedCount ~= nil and tostring(math.floor(decorOwnedCount)) or (L.COMPENDIUM_STATUS_UNKNOWN or "Unknown")
    elseif entry.category == "recipes" then
        local spellName, spellIcon = getSpellNameAndIcon(entry.spellID or entry.id)
        name = spellName or name
        icon = spellIcon or icon
        if IsPlayerSpell and tonumber(entry.spellID or entry.id) then
            local ok, known = pcall(IsPlayerSpell, tonumber(entry.spellID or entry.id))
            if ok then
                collected = known == true
            end
        end
        statusLabel = collected and (L.COMPENDIUM_STATUS_LEARNED or "Learned") or (L.COMPENDIUM_STATUS_MISSING or "Missing")
    end

    if collected == true then
        statusKey = "collected"
    elseif collected == false then
        statusKey = "missing"
    else
        statusKey = "unknown"
        statusLabel = L.COMPENDIUM_STATUS_UNKNOWN or "Unknown"
    end

    local sourceLabel = getSourceLabel(entry.source)
    local categoryLabel = getCategoryLabel(entry.category)
    local professionLabel = entry.professionKey and getProfessionLabel(entry.professionKey, entry.profession) or nil
    local searchText = normalizeSearch(table.concat({
        name or "",
        entry.name or "",
        entry.itemID or "",
        entry.spellID or "",
        entry.mountID or "",
        categoryLabel or "",
        sourceLabel or "",
        entry.zone or "",
        getLocalizedText(entry.vendor, entry.vendorKey) or "",
        getLocalizedText(entry.quest, entry.questKey) or "",
        getLocalizedText(entry.sourceDetail, entry.sourceDetailKey) or "",
        entry.cost and (getLocalizedText(entry.cost.text, entry.cost.textKey) or "") or "",
        professionLabel or "",
        entry.subcategory or "",
    }, " "))

    cached = {
        name = name,
        icon = icon,
        statusKey = statusKey,
        statusLabel = statusLabel,
        decorOwnedCount = decorOwnedCount,
        sourceLabel = sourceLabel,
        categoryLabel = categoryLabel,
        professionLabel = professionLabel,
        searchText = searchText,
        expiresAt = now + CACHE_SECONDS,
    }
    runtimeCache[key] = cached
    return cached
end

local function resolveActiveProfessionKey(skillLineID, professionName)
    local key = PROFESSION_KEY_BY_SKILL_LINE[tonumber(skillLineID)]
    if key then
        return key
    end

    local normalizedName = normalizeSearch(professionName)
    if normalizedName == "" then
        return nil
    end

    for _, prof in ipairs(DATA.professionOrder or {}) do
        if prof and prof.key then
            if normalizedName == normalizeSearch(prof.label) or normalizedName == normalizeSearch(getProfessionLabel(prof.key, prof.label)) then
                return prof.key
            end
        end
    end
    return nil
end

local function getActiveProfessionKeys()
    local now = getNow()
    if activeProfessionCache and activeProfessionCache.expiresAt and activeProfessionCache.expiresAt > now then
        return activeProfessionCache.byKey or {}, activeProfessionCache.apiAvailable == true
    end

    local byKey = {}
    local apiAvailable = type(GetProfessions) == "function" and type(GetProfessionInfo) == "function"

    if apiAvailable then
        local ok, professions = pcall(function()
            return { GetProfessions() }
        end)
        if ok and type(professions) == "table" then
            for slotIndex = 1, 6 do
                local professionIndex = professions[slotIndex]
                if professionIndex then
                    local infoOk, professionName, _, _, _, _, _, skillLineID = pcall(GetProfessionInfo, professionIndex)
                    if infoOk then
                        local key = resolveActiveProfessionKey(skillLineID, professionName)
                        if key then
                            byKey[key] = true
                        end
                    end
                end
            end
        end
    end

    activeProfessionCache = {
        byKey = byKey,
        apiAvailable = apiAvailable,
        expiresAt = now + CACHE_SECONDS,
    }
    return byKey, apiAvailable
end

local function isRecipeForActiveProfession(entry)
    if type(entry) ~= "table" or entry.category ~= "recipes" then
        return true
    end

    local activeProfessions, apiAvailable = getActiveProfessionKeys()
    if not apiAvailable then
        return true
    end
    return activeProfessions[tostring(entry.professionKey or "")] == true
end

local function getCompendiumState()
    ns.db = type(ns.db) == "table" and ns.db or {}
    local db = ns.db
    db.ui = type(db.ui) == "table" and db.ui or {}
    db.ui.compendium = type(db.ui.compendium) == "table" and db.ui.compendium or {}
    local state = db.ui.compendium
    state.category = type(state.category) == "string" and state.category or "all"
    state.status = type(state.status) == "string" and state.status or "missing"
    state.source = type(state.source) == "string" and state.source or "all"
    state.profession = type(state.profession) == "string" and state.profession or "all"
    state.search = ""
    if state.category ~= "recipes" then
        state.profession = "all"
    elseif state.profession ~= "all" then
        local activeProfessions, apiAvailable = getActiveProfessionKeys()
        if apiAvailable and not activeProfessions[state.profession] then
            state.profession = "all"
        end
    end
    return state
end

local function isOppositeFaction(entry, runtime)
    if type(entry) ~= "table" or type(entry.faction) ~= "string" or entry.faction == "" then
        return false
    end
    if runtime and runtime.statusKey == "collected" then
        return false
    end
    if UnitFactionGroup then
        local faction = UnitFactionGroup("player")
        return type(faction) == "string" and faction ~= "" and entry.faction ~= faction
    end
    return false
end

local function shouldSkipEntry(entry, runtime)
    if type(entry) ~= "table" then
        return true
    end
    if entry.unavailable == true and entry.showUnavailable ~= true and runtime and runtime.statusKey ~= "collected" then
        return true
    end
    if not isRecipeForActiveProfession(entry) then
        return true
    end
    return isOppositeFaction(entry, runtime)
end

local function isProgressTracked(entry)
    return type(entry) == "table"
end

local function addStats(stats, runtime, entry)
    if not isProgressTracked(entry) then
        return
    end
    stats.total = (stats.total or 0) + 1
    if runtime.statusKey == "collected" then
        stats.collected = (stats.collected or 0) + 1
    elseif runtime.statusKey == "missing" then
        stats.missing = (stats.missing or 0) + 1
    else
        stats.unknown = (stats.unknown or 0) + 1
    end
end

local function matchesBaseFilters(entry, runtime, state)
    local category = state.category or "all"
    if category ~= "all" and entry.category ~= category then
        return false
    end
    if state.profession ~= "all" and entry.professionKey ~= state.profession then
        return false
    end
    local search = normalizeSearch(state.search)
    if search ~= "" and not runtime.searchText:find(search, 1, true) then
        return false
    end
    return true
end

local function matchesSourceAndStatus(entry, runtime, state)
    if state.source ~= "all" and tostring(entry.source or "") ~= state.source then
        return false
    end
    if state.status == "collected" and runtime.statusKey ~= "collected" then
        return false
    elseif state.status == "missing" and runtime.statusKey ~= "missing" then
        return false
    elseif state.status == "unknown" and runtime.statusKey ~= "unknown" then
        return false
    end
    return true
end

local function getStatusSortRank(statusKey)
    if statusKey == "missing" then
        return 1
    elseif statusKey == "unknown" then
        return 2
    end
    return 3
end

local function getSourceSortRank(entry)
    local key = tostring(entry and entry.source or "")
    if entry and entry.category == "recipes" and RECIPE_SOURCE_ORDER[key] then
        return RECIPE_SOURCE_ORDER[key]
    end
    return SOURCE_RANK[key] or 99
end

local function compareEntries(a, b)
    local aRuntime = a.runtime or {}
    local bRuntime = b.runtime or {}
    local aEntry = a.entry or {}
    local bEntry = b.entry or {}
    local aCategory = CATEGORY_RANK[aEntry.category] or 99
    local bCategory = CATEGORY_RANK[bEntry.category] or 99
    if aCategory ~= bCategory then
        return aCategory < bCategory
    end
    local aProfession = PROFESSION_RANK[aEntry.professionKey] or 99
    local bProfession = PROFESSION_RANK[bEntry.professionKey] or 99
    if aProfession ~= bProfession then
        return aProfession < bProfession
    end
    local aSource = getSourceSortRank(aEntry)
    local bSource = getSourceSortRank(bEntry)
    if aSource ~= bSource then
        return aSource < bSource
    end
    local aStatus = getStatusSortRank(aRuntime.statusKey)
    local bStatus = getStatusSortRank(bRuntime.statusKey)
    if aStatus ~= bStatus then
        return aStatus < bStatus
    end
    return tostring(aRuntime.name or aEntry.name or "") < tostring(bRuntime.name or bEntry.name or "")
end

local function getGroupKey(record, state)
    local entry = record and record.entry
    if type(entry) ~= "table" then
        return "unknown"
    end
    local parts = {}
    if not state or state.category == "all" then
        parts[#parts + 1] = tostring(entry.category or "unknown")
    end
    if entry.category == "recipes" and (not state or state.profession == "all") then
        parts[#parts + 1] = tostring(entry.professionKey or "unknown")
    end
    if not state or state.source == "all" then
        parts[#parts + 1] = tostring(entry.source or "unknown")
    end
    if #parts == 0 then
        return "all"
    end
    return table.concat(parts, "\31")
end

local function getGroupLabel(record, state)
    local entry = record and record.entry
    if type(entry) ~= "table" then
        return L.UNKNOWN or "Unknown"
    end

    local parts = {}
    if not state or state.category == "all" then
        parts[#parts + 1] = getCategoryLabel(entry.category)
    end
    if entry.category == "recipes" and (not state or state.profession == "all") then
        parts[#parts + 1] = getProfessionLabel(entry.professionKey, entry.profession)
    end
    if not state or state.source == "all" then
        parts[#parts + 1] = getSourceLabel(entry.source)
    end
    if #parts == 0 then
        parts[#parts + 1] = getSourceLabel(entry.source)
    end
    return table.concat(parts, " / ")
end

local function insertGroupRows(records, state)
    if #(records or {}) == 0 then
        return records or {}
    end

    local counts = {}
    local labels = {}
    for _, record in ipairs(records) do
        local key = getGroupKey(record, state)
        counts[key] = (counts[key] or 0) + 1
        labels[key] = labels[key] or getGroupLabel(record, state)
    end

    local grouped = {}
    local lastKey
    for _, record in ipairs(records) do
        local key = getGroupKey(record, state)
        if key ~= lastKey then
            grouped[#grouped + 1] = {
                kind = "group",
                key = key,
                label = labels[key] or (L.UNKNOWN or "Unknown"),
                count = counts[key] or 0,
            }
            lastKey = key
        end
        grouped[#grouped + 1] = record
    end
    return grouped
end

local function buildView()
    local state = getCompendiumState()
    if state.category == "decorations" then
        tryLoadHousingCatalogAddOns()
    end
    local catalogWasObserved = housingCatalogObservedOwnedCount
    local categoryStats = { all = {} }
    local sourceCounts = {}
    local professionCounts = {}
    local baseEntries = {}
    local visibleEntries = {}
    local waypointCount = 0

    for _, categoryKey in ipairs(CATEGORY_ORDER) do
        categoryStats[categoryKey] = categoryStats[categoryKey] or {}
    end

    for _, entry in ipairs(ENTRIES) do
        local runtime = getRuntimeInfo(entry)
        if not shouldSkipEntry(entry, runtime) then
            categoryStats[entry.category] = categoryStats[entry.category] or {}
            addStats(categoryStats[entry.category], runtime, entry)
            addStats(categoryStats.all, runtime, entry)
            if entry.waypoint or entry.overworldWaypoint then
                waypointCount = waypointCount + 1
            end

            if matchesBaseFilters(entry, runtime, state) then
                baseEntries[#baseEntries + 1] = { entry = entry, runtime = runtime }
                local source = tostring(entry.source or "unknown")
                sourceCounts[source] = (sourceCounts[source] or 0) + 1
                if entry.category == "recipes" and entry.professionKey then
                    professionCounts[entry.professionKey] = (professionCounts[entry.professionKey] or 0) + 1
                end
                if matchesSourceAndStatus(entry, runtime, state) then
                    visibleEntries[#visibleEntries + 1] = { entry = entry, runtime = runtime }
                end
            end
        end
    end

    if not catalogWasObserved and housingCatalogObservedOwnedCount then
        runtimeCache = {}
        return buildView()
    end

    table.sort(visibleEntries, compareEntries)

    local sourceOptions = { { key = "all", label = L.COMPENDIUM_FILTER_SOURCE_ALL or "All sources", count = #baseEntries } }
    for source, count in pairs(sourceCounts) do
        sourceOptions[#sourceOptions + 1] = {
            key = source,
            label = getSourceLabel(source),
            count = count,
        }
    end
    table.sort(sourceOptions, function(a, b)
        if a.key == "all" then return true end
        if b.key == "all" then return false end
        if a.count ~= b.count then return a.count > b.count end
        return tostring(a.label or "") < tostring(b.label or "")
    end)

    local activeProfessions, professionApiAvailable = getActiveProfessionKeys()
    local professionOptions = { { key = "all", label = L.COMPENDIUM_FILTER_PROFESSION_ALL or "All professions", count = 0 } }
    for _, prof in ipairs(DATA.professionOrder or {}) do
        if not professionApiAvailable or activeProfessions[prof.key] then
            professionOptions[#professionOptions + 1] = {
                key = prof.key,
                label = getProfessionLabel(prof.key, prof.label),
                count = professionCounts[prof.key] or 0,
            }
            professionOptions[1].count = professionOptions[1].count + (professionCounts[prof.key] or 0)
        end
    end

    local selectedSourceValid = false
    for _, option in ipairs(sourceOptions) do
        if option.key == state.source then
            selectedSourceValid = true
            break
        end
    end
    if not selectedSourceValid then
        state.source = "all"
        visibleEntries = {}
        for _, record in ipairs(baseEntries) do
            if matchesSourceAndStatus(record.entry, record.runtime, state) then
                visibleEntries[#visibleEntries + 1] = record
            end
        end
        table.sort(visibleEntries, compareEntries)
    end

    return {
        state = state,
        entries = insertGroupRows(visibleEntries, state),
        categoryStats = categoryStats,
        sourceOptions = sourceOptions,
        professionOptions = professionOptions,
        waypointCount = waypointCount,
    }
end

local function formatProgress(stats)
    stats = stats or {}
    if (tonumber(stats.total) or 0) <= 0 then
        return ""
    end
    return string.format(L.COMPENDIUM_PROGRESS_FORMAT or "%d/%d", tonumber(stats.collected) or 0, tonumber(stats.total) or 0)
end

local function setButtonText(button, text, minWidth, maxWidth)
    if not button then
        return
    end
    if button.SetButtonText then
        button:SetButtonText(text or "", minWidth, maxWidth)
    elseif button.label then
        button.label:SetText(text or "")
    elseif button.SetText then
        button:SetText(text or "")
    end
end

local function createStatLabel(parent, anchor)
    local label = createLabel(parent, "GameFontHighlightSmall", "LEFT")
    if anchor then
        label:SetPoint("LEFT", anchor, "RIGHT", 28, 0)
    else
        label:SetPoint("TOPLEFT", 14, -12)
    end
    label:SetTextColor(PARCHMENT[1], PARCHMENT[2], PARCHMENT[3], 0.94)
    return label
end

local function getWaypoint(entry)
    if type(entry) ~= "table" then
        return nil
    end
    local waypoint = entry.waypoint
    if entry.overworldWaypoint and entry.waypoint and C_Map and type(C_Map.GetBestMapForUnit) == "function" then
        local ok, currentMapID = pcall(C_Map.GetBestMapForUnit, "player")
        if ok and tonumber(currentMapID) ~= tonumber(entry.waypoint[1]) then
            waypoint = entry.overworldWaypoint
        end
    elseif entry.overworldWaypoint then
        waypoint = entry.overworldWaypoint
    end
    if type(waypoint) == "table" and tonumber(waypoint[1]) and tonumber(waypoint[2]) and tonumber(waypoint[3]) then
        return waypoint
    end
    return nil
end

local function getWaypointLabel(entry)
    local waypoint = getWaypoint(entry)
    local label = type(waypoint) == "table" and waypoint[4] or nil
    if type(label) ~= "string" or label == "" then
        return nil
    end
    label = label:gsub("%s*%b()", "")
    label = label:gsub(",%s*" .. tostring(entry and entry.zone or "") .. "%s*$", "")
    label = label:gsub(",.*$", "")
    label = label:gsub("%s+$", "")
    return label ~= "" and label or nil
end

local function getMapName(mapID)
    mapID = tonumber(mapID)
    if not mapID or not C_Map or type(C_Map.GetMapInfo) ~= "function" then
        return nil
    end
    local ok, info = pcall(C_Map.GetMapInfo, mapID)
    if ok and type(info) == "table" and type(info.name) == "string" and info.name ~= "" then
        return info.name
    end
    return nil
end

local function getZoneMapName(zone)
    local mapID = ZONE_MAP_IDS[tostring(zone or "")]
    return mapID and getMapName(mapID) or nil
end

local function getWaypointMapName(entry)
    local waypoint = getWaypoint(entry)
    return waypoint and getMapName(waypoint[1]) or nil
end

local function getEntryLocationName(entry)
    if type(entry) ~= "table" then
        return nil
    end
    return getWaypointMapName(entry) or getZoneMapName(entry.zone)
end

local function formatWaypoint(waypoint)
    if type(waypoint) ~= "table" then
        return ""
    end
    local mapName = getMapName(waypoint[1]) or L.COMPENDIUM_WAYPOINT or "Waypoint"
    return string.format("%s %.1f, %.1f", mapName, (tonumber(waypoint[2]) or 0) * 100, (tonumber(waypoint[3]) or 0) * 100)
end

local function setWaypoint(entry, runtime)
    local waypoint = getWaypoint(entry)
    if not waypoint then
        return false
    end

    if not C_Map
        or type(C_Map.SetUserWaypoint) ~= "function"
        or not UiMapPoint
        or type(UiMapPoint.CreateFromCoordinates) ~= "function"
    then
        if ns.Print then
            ns:Print(L.COMPENDIUM_WAYPOINT_UNAVAILABLE or "Blizzard waypoint API is not available.")
        end
        return false
    end

    local okPoint, point = pcall(UiMapPoint.CreateFromCoordinates, tonumber(waypoint[1]), tonumber(waypoint[2]), tonumber(waypoint[3]))
    if not okPoint or not point then
        return false
    end

    local okWaypoint = pcall(C_Map.SetUserWaypoint, point)
    if okWaypoint and C_SuperTrack and type(C_SuperTrack.SetSuperTrackedUserWaypoint) == "function" then
        pcall(C_SuperTrack.SetSuperTrackedUserWaypoint, true)
    end
    if okWaypoint and ns.Print then
        ns:Print(string.format(L.COMPENDIUM_WAYPOINT_SET_FORMAT or "Waypoint set: %s", runtime and runtime.name or entry.name or ""))
    end
    return okWaypoint == true
end

local function openAchievement(entry)
    local achievementID = tonumber(entry and entry.achievementID)
    if not achievementID then
        return false
    end

    if OpenAchievementFrame then
        local ok = pcall(OpenAchievementFrame, achievementID)
        if ok then
            return true
        end
    end
    if AchievementFrame_LoadUI then
        pcall(AchievementFrame_LoadUI)
    end
    if ToggleAchievementFrame and (not AchievementFrame or not AchievementFrame:IsShown()) then
        pcall(ToggleAchievementFrame)
    end
    if AchievementFrame_SelectAchievement then
        local ok = pcall(AchievementFrame_SelectAchievement, achievementID)
        if ok then
            return true
        end
    end
    return false
end

local function formatCurrency(currencyInfo)
    if type(currencyInfo) ~= "table" then
        return nil
    end
    local currencyID = tonumber(currencyInfo[1])
    local amount = tonumber(currencyInfo[2]) or 0
    local name = tostring(currencyID or "")
    local icon
    if C_CurrencyInfo and type(C_CurrencyInfo.GetCurrencyInfo) == "function" and currencyID then
        local ok, info = pcall(C_CurrencyInfo.GetCurrencyInfo, currencyID)
        if ok and type(info) == "table" and info.name and info.name ~= "" then
            name = info.name
            icon = info.iconFileID or info.icon
        end
    end
    if icon then
        return string.format("%s |T%s:14:14:0:0|t %s", amount, tostring(icon), name)
    end
    return string.format("%s %s", amount, name)
end

local function formatItemCost(itemInfo)
    if type(itemInfo) ~= "table" then
        return nil
    end
    local itemID = tonumber(itemInfo.itemID or itemInfo[1])
    if not itemID then
        return nil
    end
    local amount = tonumber(itemInfo.amount or itemInfo[2]) or 1
    local name, icon = getItemNameAndIcon(itemID)
    name = name or tostring(itemID)
    if icon then
        return string.format("%s |T%s:14:14:0:0|t %s", amount, tostring(icon), name)
    end
    return string.format("%s %s", amount, name)
end

local function formatCost(cost)
    if type(cost) ~= "table" then
        return nil
    end
    local parts = {}
    local textCost = getLocalizedText(cost.text, cost.textKey)
    if textCost then
        parts[#parts + 1] = textCost
    end
    for _, key in ipairs({ "currency", "currency2", "currency3" }) do
        local part = formatCurrency(cost[key])
        if part then
            parts[#parts + 1] = part
        end
    end
    for _, key in ipairs({ "item", "item2", "item3" }) do
        local part = formatItemCost(cost[key])
        if part then
            parts[#parts + 1] = part
        end
    end
    if tonumber(cost.gold) then
        parts[#parts + 1] = string.format(L.COMPENDIUM_COST_GOLD_FORMAT or "%d gold", tonumber(cost.gold))
    end
    return #parts > 0 and table.concat(parts, ", ") or nil
end

local function formatAchievementName(entry)
    local achievementID = type(entry) == "table" and tonumber(entry.achievementID) or nil
    if not achievementID or not GetAchievementInfo then
        return nil
    end
    local ok, id, name = pcall(GetAchievementInfo, achievementID)
    if ok and id and type(name) == "string" and name ~= "" then
        return name
    end
    return nil
end

local function formatRenown(renown)
    if type(renown) ~= "table" then
        return nil
    end
    local faction = renown.factionName or renown.name
    local factionID = tonumber(renown.factionID)
    if factionID and C_MajorFactions and type(C_MajorFactions.GetMajorFactionData) == "function" then
        local ok, info = pcall(C_MajorFactions.GetMajorFactionData, factionID)
        if ok and type(info) == "table" and type(info.name) == "string" and info.name ~= "" then
            faction = info.name
        end
    end
    if factionID and GetFactionInfoByID then
        local ok, name = pcall(GetFactionInfoByID, factionID)
        if ok and type(name) == "string" and name ~= "" then
            faction = name
        end
    end
    if renown.level and faction then
        return string.format(L.COMPENDIUM_RENOWN_LEVEL_FORMAT or "%s renown %d", faction, tonumber(renown.level) or 0)
    end
    if renown.standing and faction then
        return string.format("%s - %s", faction, tostring(renown.standing))
    end
    return faction
end

local function formatDropSource(dropInfo)
    if type(dropInfo) ~= "table" then
        return nil
    end
    local name = dropInfo.mob or dropInfo.object or dropInfo.container
    if type(name) ~= "string" or name == "" then
        return nil
    end
    local nameKey = dropInfo.nameKey or dropInfo.mobKey or dropInfo.objectKey or dropInfo.containerKey
    if not nameKey and name == "Zone rares" then
        nameKey = "COMPENDIUM_DROP_ZONE_RARES"
    end
    name = getLocalizedText(name, nameKey) or name
    return string.format(L.COMPENDIUM_SOURCE_DROP_FROM_FORMAT or "Drop from: %s", name)
end

local function getShortWaypointName(entry)
    local name = getWaypointMapName(entry)
    if type(name) ~= "string" or name == "" then
        return nil
    end
    name = name:gsub("%s*%b()", "")
    name = name:gsub(",.*$", "")
    return name ~= "" and name or nil
end

local function joinHintParts(...)
    local parts = {}
    for index = 1, select("#", ...) do
        local value = select(index, ...)
        if type(value) == "number" then
            value = tostring(value)
        end
        if type(value) == "string" and value ~= "" then
            parts[#parts + 1] = value
        end
    end
    return table.concat(parts, " - ")
end

local function getEntryDetail(entry)
    if type(entry) ~= "table" then
        return nil
    end
    return getLocalizedText(entry.sourceDetail, entry.sourceDetailKey) or getLocalizedText(entry.quest, entry.questKey)
end

local function getEntryActorLabel(entry)
    if type(entry) ~= "table" then
        return nil
    end
    local vendor = getLocalizedText(entry.vendor, entry.vendorKey)
    if vendor then
        return vendor
    end
    return getWaypointLabel(entry)
end

local function formatEntryHint(entry, runtime)
    if type(entry) ~= "table" then
        return ""
    end

    runtime = runtime or {}
    local source = runtime.sourceLabel or getSourceLabel(entry.source)
    local place = getEntryLocationName(entry)
    local achievement = formatAchievementName(entry)
    local renown = formatRenown(entry.renown)
    local cost = formatCost(entry.cost)
    local drop = formatDropSource(entry.dropInfo)
    local detail = getEntryDetail(entry)

    if entry.category == "recipes" then
        return joinHintParts(runtime.professionLabel, source, cost, place)
    end
    if achievement then
        return joinHintParts(source, achievement, detail, place)
    end
    if detail then
        return joinHintParts(source, detail, cost, place)
    end
    if renown then
        return joinHintParts(source, renown, cost, place)
    end
    if drop then
        return joinHintParts(drop, place)
    end
    if cost then
        return joinHintParts(source, cost, place)
    end
    return joinHintParts(source, place)
end

local function formatSourceDetail(entry, runtime)
    if type(entry) ~= "table" then
        return L.COMPENDIUM_STATUS_UNKNOWN or "Unknown"
    end

    runtime = runtime or {}
    local source = runtime.sourceLabel or getSourceLabel(entry.source)
    local achievement = formatAchievementName(entry)
    local renown = formatRenown(entry.renown)
    local cost = formatCost(entry.cost)
    local actor = getEntryActorLabel(entry)
    local drop = formatDropSource(entry.dropInfo)
    local detail = getEntryDetail(entry)

    if renown then
        return joinHintParts(source, renown, cost)
    end
    if achievement then
        return joinHintParts(source, achievement, detail)
    end
    if detail then
        return joinHintParts(source, detail, cost)
    end
    if drop then
        return drop
    end
    if actor then
        return joinHintParts(source, actor, cost)
    end
    if entry.category == "recipes" then
        return joinHintParts(runtime.professionLabel, source, cost)
    end
    return source ~= "" and source or (L.COMPENDIUM_STATUS_UNKNOWN or "Unknown")
end

local function isVendorLikeSource(entry)
    local source = tostring(entry and entry.source or "")
    return source == "vendor" or source == "renown" or source == "reputation" or source == "delve" or source == "pvp" or source == "worldevent"
end

local function formatTooltipSource(entry, runtime)
    if type(entry) ~= "table" then
        return L.COMPENDIUM_STATUS_UNKNOWN or "Unknown"
    end

    runtime = runtime or {}
    local source = runtime.sourceLabel or getSourceLabel(entry.source)
    local achievement = formatAchievementName(entry)
    local renown = formatRenown(entry.renown)
    local actor = getEntryActorLabel(entry)
    local drop = formatDropSource(entry.dropInfo)
    local detail = getEntryDetail(entry)

    if achievement then
        return joinHintParts(source, achievement, detail)
    end
    if detail then
        return joinHintParts(source, detail)
    end
    if drop then
        return drop
    end
    if renown then
        return joinHintParts(source, renown)
    end
    if entry.category == "recipes" then
        return joinHintParts(runtime.professionLabel, source)
    end
    if actor and not isVendorLikeSource(entry) then
        return joinHintParts(source, actor)
    end
    return source ~= "" and source or (L.COMPENDIUM_STATUS_UNKNOWN or "Unknown")
end

local function formatTooltipLabel(label)
    if type(label) ~= "string" or label == "" then
        return ""
    end
    return label .. ":"
end

local function addTooltipLine(label, value)
    if type(value) ~= "string" and type(value) ~= "number" then
        return
    end
    value = tostring(value)
    if value == "" then
        return
    end
    GameTooltip:AddDoubleLine(formatTooltipLabel(label), value, GOLD[1], GOLD[2], GOLD[3], 0.92, 0.92, 0.88)
end

local function addEntryTooltipDetails(entry, runtime)
    local unknown = L.COMPENDIUM_STATUS_UNKNOWN or "Unknown"
    local zone = getEntryLocationName(entry) or entry.zone
    local vendor = getLocalizedText(entry.vendor, entry.vendorKey)
    if not vendor and isVendorLikeSource(entry) then
        vendor = getWaypointLabel(entry)
    end
    local cost = formatCost(entry.cost)
    local source = formatTooltipSource(entry, runtime)

    GameTooltip:AddLine(" ")
    addTooltipLine(L.COMPENDIUM_TOOLTIP_ZONE or "Zone", zone or unknown)
    if vendor then
        addTooltipLine(L.COMPENDIUM_TOOLTIP_VENDOR or "Vendor", vendor)
    end
    if cost then
        addTooltipLine(L.COMPENDIUM_TOOLTIP_COST or "Cost", cost)
    end
    addTooltipLine(L.COMPENDIUM_TOOLTIP_SOURCE or "Source", source or unknown)
end

local function showEntryTooltip(owner, entry, runtime)
    if not owner or type(entry) ~= "table" then
        return
    end

    GameTooltip:SetOwner(owner, "ANCHOR_CURSOR_RIGHT")
    local usedNativeTooltip = false
    if entry.itemID and GameTooltip.SetItemByID then
        usedNativeTooltip = pcall(GameTooltip.SetItemByID, GameTooltip, tonumber(entry.itemID)) == true
    elseif (entry.spellID or entry.id) and GameTooltip.SetSpellByID then
        usedNativeTooltip = pcall(GameTooltip.SetSpellByID, GameTooltip, tonumber(entry.spellID or entry.id)) == true
    elseif entry.category == "mounts" and entry.mountID and GameTooltip.SetSpellByID
        and C_MountJournal and type(C_MountJournal.GetMountInfoByID) == "function" then
        local ok, _, spellID = pcall(C_MountJournal.GetMountInfoByID, tonumber(entry.mountID))
        if ok and spellID then
            usedNativeTooltip = pcall(GameTooltip.SetSpellByID, GameTooltip, tonumber(spellID)) == true
        end
    end

    if not usedNativeTooltip then
        GameTooltip:AddLine(runtime.name or entry.name or L.UNKNOWN or "Unknown", GOLD[1], GOLD[2], GOLD[3], true)
    end
    addEntryTooltipDetails(entry, runtime)
    GameTooltip:Show()
end

local function showWaypointTooltip(owner)
    if not owner then
        return
    end

    GameTooltip:SetOwner(owner, "ANCHOR_CURSOR_RIGHT")
    GameTooltip:AddLine(L.COMPENDIUM_TOOLTIP_SET_WAYPOINT or L.COMPENDIUM_WAYPOINT or "Set waypoint", GOLD[1], GOLD[2], GOLD[3], true)
    GameTooltip:AddLine(L.COMPENDIUM_TOOLTIP_CLICK_WAYPOINT or "Left-click to set a Blizzard waypoint.", 0.86, 0.86, 0.82, true)
    GameTooltip:Show()
end

local function createCategoryButton(parent, helpers)
    local button = CreateFrame("Button", nil, parent, BACKDROP_TEMPLATE)
    button:SetHeight(34)
    button:RegisterForClicks("LeftButtonUp")
    if button.SetBackdrop then
        button:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            tile = false,
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        button:SetBackdropColor(METAL[1], METAL[2], METAL[3], 0.86)
        button:SetBackdropBorderColor(GOLD_DIM[1], GOLD_DIM[2], GOLD_DIM[3], 0.46)
    end
    button.bg = button:CreateTexture(nil, "BACKGROUND")
    button.bg:SetAllPoints()
    button.bg:SetTexture(PREMIUM.row or "Interface\\Buttons\\WHITE8X8")
    setTextureColor(button.bg, { 1, 1, 1 }, 0.96)
    button.active = button:CreateTexture(nil, "BACKGROUND", nil, 1)
    button.active:SetAllPoints()
    button.active:SetTexture("Interface\\Buttons\\WHITE8X8")
    button.active:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.08)
    button.active:Hide()
    button.icon = button:CreateTexture(nil, "ARTWORK")
    button.icon:SetSize(23, 23)
    button.icon:SetPoint("LEFT", 8, 0)
    button.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    button.iconBorder = button:CreateTexture(nil, "OVERLAY")
    button.iconBorder:SetSize(27, 27)
    button.iconBorder:SetPoint("CENTER", button.icon, "CENTER", 0, 0)
    button.iconBorder:SetTexture(ICON_BORDER)
    button.label = createLabel(button, "GameFontHighlightSmall", "LEFT")
    button.label:SetPoint("LEFT", button.icon, "RIGHT", 8, 6)
    button.label:SetPoint("RIGHT", -8, 6)
    button.meta = createLabel(button, "GameFontDisableSmall", "LEFT")
    button.meta:SetPoint("LEFT", button.icon, "RIGHT", 8, -8)
    button.meta:SetPoint("RIGHT", -8, -8)
    return button
end

local function createEntryRow(parent, helpers)
    local row = CreateFrame("Button", nil, parent, BACKDROP_TEMPLATE)
    row:SetHeight(ROW_HEIGHT)
    row:RegisterForClicks("LeftButtonUp")
    if row.SetBackdrop then
        row:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            tile = false,
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        row:SetBackdropColor(METAL[1], METAL[2], METAL[3], 0.88)
        row:SetBackdropBorderColor(GOLD_DIM[1], GOLD_DIM[2], GOLD_DIM[3], 0.32)
    end
    row.bg = row:CreateTexture(nil, "BACKGROUND")
    row.bg:SetAllPoints()
    row.bg:SetTexture("Interface\\Buttons\\WHITE8X8")
    row.bg:SetColorTexture(METAL[1], METAL[2], METAL[3], 0.84)
    row.hover = row:CreateTexture(nil, "BACKGROUND", nil, 1)
    row.hover:SetAllPoints()
    row.hover:SetTexture("Interface\\Buttons\\WHITE8X8")
    row.hover:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.10)
    row.hover:SetAlpha(0)
    row.statusLine = row:CreateTexture(nil, "ARTWORK")
    row.statusLine:SetPoint("TOPLEFT", 4, -4)
    row.statusLine:SetPoint("BOTTOMLEFT", 4, 4)
    row.statusLine:SetWidth(2)
    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(27, 27)
    row.icon:SetPoint("LEFT", 12, 0)
    row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    row.waypointButton = CreateFrame("Button", nil, row)
    row.waypointButton:SetSize(23, 23)
    row.waypointButton:RegisterForClicks("LeftButtonUp")
    row.waypointButton.icon = row.waypointButton:CreateTexture(nil, "ARTWORK")
    row.waypointButton.icon:SetSize(21, 21)
    row.waypointButton.icon:SetPoint("CENTER", 0, 0)
    row.waypointButton.icon:SetTexture(WAYPOINT_ICON)
    row.waypointButton.icon:SetTexCoord(0, 1, 0, 1)
    row.waypointButton.hover = row.waypointButton:CreateTexture(nil, "ARTWORK", nil, 1)
    row.waypointButton.hover:SetSize(21, 21)
    row.waypointButton.hover:SetPoint("CENTER", 0, 0)
    row.waypointButton.hover:SetTexture(WAYPOINT_ICON)
    row.waypointButton.hover:SetVertexColor(1, 1, 0.55, 0.55)
    row.waypointButton.hover:SetAlpha(0)
    row.waypointButton.border = row.waypointButton:CreateTexture(nil, "OVERLAY")
    row.waypointButton.border:SetSize(24, 24)
    row.waypointButton.border:SetPoint("CENTER", 0, 0)
    row.waypointButton.border:SetTexture(ICON_BORDER)
    row.status = createLabel(row, "GameFontDisableSmall", "RIGHT")
    row.status:SetPoint("RIGHT", -10, 0)
    row.status:SetWidth(78)
    row.category = createLabel(row, "GameFontDisableSmall", "RIGHT")
    row.category:SetPoint("RIGHT", row.status, "LEFT", -6, 0)
    row.category:SetWidth(128)
    row.name = createLabel(row, "GameFontHighlightSmall", "LEFT")
    row.name:SetPoint("LEFT", row.icon, "RIGHT", 9, 0)
    row.name:SetPoint("RIGHT", row.status, "LEFT", -8, 0)
    row.name:SetWordWrap(false)
    if row.name.SetMaxLines then
        row.name:SetMaxLines(1)
    end
    row.meta = createLabel(row, "GameFontDisableSmall", "LEFT")
    row.meta:SetPoint("LEFT", row.icon, "RIGHT", 10, -10)
    row.meta:SetPoint("RIGHT", row.category, "LEFT", -10, -10)
    row.meta:SetWordWrap(false)
    if row.meta.SetMaxLines then
        row.meta:SetMaxLines(1)
    end
    row.groupTitle = createLabel(row, "GameFontNormalSmall", "LEFT")
    row.groupTitle:SetPoint("LEFT", 14, 0)
    row.groupTitle:SetPoint("RIGHT", -72, 0)
    row.groupTitle:SetWordWrap(false)
    if row.groupTitle.SetMaxLines then
        row.groupTitle:SetMaxLines(1)
    end
    row.groupCount = createLabel(row, "GameFontDisableSmall", "RIGHT")
    row.groupCount:SetPoint("RIGHT", -16, 0)
    row.groupLine = row:CreateTexture(nil, "ARTWORK")
    row.groupLine:SetPoint("BOTTOMLEFT", 14, 5)
    row.groupLine:SetPoint("BOTTOMRIGHT", -14, 5)
    row.groupLine:SetHeight(1)
    row.groupLine:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.26)
    row.groupTitle:Hide()
    row.groupCount:Hide()
    row.groupLine:Hide()
    row:SetScript("OnEnter", function(self)
        self.hover:SetAlpha(1)
        if not self.vlGroup then
            showEntryTooltip(self, self.entry, self.runtime)
        end
    end)
    row:SetScript("OnLeave", function(self)
        self.hover:SetAlpha(0)
        GameTooltip:Hide()
    end)
    row:SetScript("OnClick", function(self)
        if self.vlGroup then
            return
        end
        openAchievement(self.entry)
    end)
    row.waypointButton:SetScript("OnEnter", function(self)
        if self.hover then
            self.hover:SetAlpha(1)
        end
        showWaypointTooltip(self)
    end)
    row.waypointButton:SetScript("OnLeave", function(self)
        if self.hover then
            self.hover:SetAlpha(0)
        end
        GameTooltip:Hide()
    end)
    row.waypointButton:SetScript("OnClick", function(self)
        local parentRow = self:GetParent()
        setWaypoint(parentRow and parentRow.entry, parentRow and parentRow.runtime)
    end)
    return row
end

local function refreshCategoryButtons(card, view)
    for index, button in ipairs(card.categoryButtons or {}) do
        local key = CATEGORY_ORDER[index]
        if key then
            local stats = view.categoryStats[key] or {}
            button.categoryKey = key
            applyIconTexture(button.icon, getCategoryIcon(key), true)
            button.label:SetText(getCategoryLabel(key))
            button.meta:SetText(formatProgress(stats))
            button.active:SetShown(view.state.category == key)
            button:SetScript("OnClick", function(self)
                local state = getCompendiumState()
                state.category = self.categoryKey or "all"
                state.source = "all"
                if state.category ~= "recipes" then
                    state.profession = "all"
                end
                if state.category == "decorations" then
                    tryLoadHousingCatalogAddOns()
                end
                CompendiumView.RefreshCard(card.ui, nil, card.helpers, true)
            end)
            button:Show()
        else
            button:Hide()
        end
    end
end

local function getSelectedOption(options, selectedKey)
    for index, option in ipairs(options or {}) do
        if option.key == selectedKey then
            return option, index
        end
    end
    return options and options[1], 1
end

local function cycleOption(options, selectedKey, direction)
    local _, index = getSelectedOption(options, selectedKey)
    local count = #(options or {})
    if count <= 0 then
        return "all"
    end
    index = index + (direction or 1)
    if index > count then
        index = 1
    elseif index < 1 then
        index = count
    end
    return options[index].key
end

local function refreshToolbar(card, view)
    local state = view.state
    state.search = ""

    for _, button in ipairs(card.statusButtons or {}) do
        local active = button.statusKey == state.status
        setMiniActionButtonActive(button, active, card.helpers)
    end

    local sourceOption = getSelectedOption(view.sourceOptions, state.source)
    setButtonText(card.sourceButton, string.format(
        L.COMPENDIUM_FILTER_SOURCE_FORMAT or "Source: %s",
        sourceOption and sourceOption.label or (L.COMPENDIUM_FILTER_SOURCE_ALL or "All sources")
    ), 132, 184)
    card.sourceButton.vlOptions = view.sourceOptions

    local showProfession = state.category == "recipes"
    card.professionButton:SetShown(showProfession)
    if showProfession then
        local professionOption = getSelectedOption(view.professionOptions, state.profession)
        setButtonText(card.professionButton, string.format(
            L.COMPENDIUM_FILTER_PROFESSION_FORMAT or "Profession: %s",
            professionOption and professionOption.label or (L.COMPENDIUM_FILTER_PROFESSION_ALL or "All professions")
        ), 150, 210)
        card.professionButton.vlOptions = view.professionOptions
    end
end

local function setRowRightAnchors(row, hasWaypoint)
    row.status:ClearAllPoints()
    row.category:ClearAllPoints()
    row.waypointButton:ClearAllPoints()
    row.name:ClearAllPoints()
    row.meta:ClearAllPoints()

    row.status:SetPoint("RIGHT", -10, 0)
    row.waypointButton:SetPoint("RIGHT", row.status, "LEFT", -4, 0)
    row.category:SetPoint("RIGHT", hasWaypoint and row.waypointButton or row.status, "LEFT", -6, 0)
    row.name:SetPoint("LEFT", row.icon, "RIGHT", 9, 0)
    row.name:SetPoint("RIGHT", hasWaypoint and row.waypointButton or row.status, "LEFT", -8, 0)
    row.meta:SetPoint("LEFT", row.icon, "RIGHT", 10, -10)
    row.meta:SetPoint("RIGHT", row.category, "LEFT", -10, -10)
end

local function fitRowStatusWidth(row)
    if not row or not row.status or type(row.status.GetStringWidth) ~= "function" then
        return
    end
    row.status:SetWidth(clamp((row.status:GetStringWidth() or 0) + 4, 36, 96))
end

local function setRow(row, record)
    if record and record.kind == "group" then
        row.vlGroup = true
        row.entry = nil
        row.runtime = nil
        row.icon:Hide()
        row.name:Hide()
        row.meta:Hide()
        row.status:Hide()
        row.category:Hide()
        row.statusLine:Hide()
        row.waypointButton:Hide()
        row.groupTitle:SetText(record.label or "")
        row.groupCount:SetText(tostring(record.count or ""))
        row.groupTitle:Show()
        row.groupCount:Show()
        row.groupLine:Show()
        if row.SetBackdropColor then
            row:SetBackdropColor(METAL[1], METAL[2], METAL[3], 0.58)
        end
        row.bg:SetAlpha(0.45)
        row:Show()
        return
    end

    local entry = record.entry
    local runtime = record.runtime
    local color = getStatusColor(runtime.statusKey)
    local hasWaypoint = getWaypoint(entry) ~= nil
    row.vlGroup = false
    row.entry = entry
    row.runtime = runtime
    row.icon:Show()
    row.name:Show()
    row.meta:Hide()
    row.status:Show()
    row.category:Hide()
    row.statusLine:Show()
    row.groupTitle:Hide()
    row.groupCount:Hide()
    row.groupLine:Hide()
    row.waypointButton:SetShown(hasWaypoint)
    applyIconTexture(row.icon, runtime.icon or getCategoryIcon(entry.category), false)
    row.name:SetText(runtime.name or entry.name or "")
    row.meta:SetText("")
    row.status:SetText(runtime.statusLabel or "")
    fitRowStatusWidth(row)
    setRowRightAnchors(row, hasWaypoint)
    row.status:SetTextColor(color[1], color[2], color[3], 1)
    row.category:SetText(runtime.categoryLabel or getCategoryLabel(entry.category))
    row.statusLine:SetColorTexture(color[1], color[2], color[3], 0.92)
    if row.SetBackdropColor then
        row:SetBackdropColor(METAL[1], METAL[2], METAL[3], 0.88)
    end
    row.bg:SetAlpha(1)
    row:Show()
end

local function buildLayoutRows(records)
    local layoutRows = {}
    local pendingEntries = nil

    local function flushPending()
        if pendingEntries then
            layoutRows[#layoutRows + 1] = {
                kind = "entries",
                entries = pendingEntries,
            }
            pendingEntries = nil
        end
    end

    for _, record in ipairs(records or {}) do
        if record and record.kind == "group" then
            flushPending()
            layoutRows[#layoutRows + 1] = {
                kind = "group",
                record = record,
            }
        elseif record then
            pendingEntries = pendingEntries or {}
            pendingEntries[#pendingEntries + 1] = record
            if #pendingEntries >= 2 then
                flushPending()
            end
        end
    end

    flushPending()
    return layoutRows
end

function CompendiumView.UpdateVisibleRows(card)
    if not card or not card.scroll or not card.scrollChild then
        return
    end

    local layoutRows = card.layoutRows or {}
    local total = #layoutRows
    local scrollHeight = math.max(120, math.floor(card.scroll:GetHeight() or 420))
    local visibleCount = math.max(8, math.ceil(scrollHeight / ROW_STEP) + 2)
    local offset = tonumber(card.scroll:GetVerticalScroll()) or 0
    local firstIndex = math.floor(offset / ROW_STEP) + 1
    firstIndex = clamp(firstIndex, 1, math.max(1, total))

    card.rows = card.rows or {}
    while #card.rows < visibleCount * 2 do
        local row = createEntryRow(card.scrollChild, card.helpers or {})
        card.rows[#card.rows + 1] = row
    end

    for _, row in ipairs(card.rows) do
        row.entry = nil
        row.runtime = nil
        row:Hide()
    end

    local scrollWidth = math.max(520, math.floor(card.scrollChild:GetWidth() or card.scroll:GetWidth() or 680))
    local columnWidth = math.floor((scrollWidth - ROW_COLUMN_GAP) / 2)

    for visualIndex = firstIndex, math.min(total, firstIndex + visibleCount - 1) do
        local visualRow = layoutRows[visualIndex]
        local rowOffset = visualIndex - firstIndex
        local leftRow = card.rows[(rowOffset * 2) + 1]
        local rightRow = card.rows[(rowOffset * 2) + 2]
        local yOffset = -((visualIndex - 1) * ROW_STEP)
        if visualRow and visualRow.kind == "group" then
            leftRow:ClearAllPoints()
            leftRow:SetPoint("TOPLEFT", card.scrollChild, "TOPLEFT", 0, yOffset)
            leftRow:SetPoint("TOPRIGHT", card.scrollChild, "TOPRIGHT", 0, yOffset)
            setRow(leftRow, visualRow.record)
        elseif visualRow and visualRow.kind == "entries" then
            local entries = visualRow.entries or {}
            if entries[1] then
                leftRow:ClearAllPoints()
                leftRow:SetPoint("TOPLEFT", card.scrollChild, "TOPLEFT", 0, yOffset)
                leftRow:SetSize(columnWidth, ROW_HEIGHT)
                setRow(leftRow, entries[1])
            end
            if entries[2] then
                rightRow:ClearAllPoints()
                rightRow:SetPoint("TOPLEFT", card.scrollChild, "TOPLEFT", columnWidth + ROW_COLUMN_GAP, yOffset)
                rightRow:SetSize(columnWidth, ROW_HEIGHT)
                setRow(rightRow, entries[2])
            end
        else
            leftRow:Hide()
            rightRow:Hide()
        end
    end

    card.empty:SetShown(#(card.filteredEntries or {}) == 0)
end

local function refreshList(card, view, forceTop)
    local records = view.entries or {}
    card.filteredEntries = records
    card.layoutRows = buildLayoutRows(records)
    local scrollWidth = math.max(520, math.floor((card.scroll:GetWidth() or 680) - 4))
    local totalHeight = math.max(1, (#card.layoutRows * ROW_STEP) - ROW_GAP)
    card.scrollChild:SetSize(scrollWidth, totalHeight)
    if forceTop and card.scroll.SetVerticalScroll then
        card.scroll:SetVerticalScroll(0)
        if card.scroll.ScrollBar and card.scroll.ScrollBar.SetValue then
            card.scroll.ScrollBar:SetValue(0)
        end
    end
    if card.scroll.UpdateScrollChildRect then
        card.scroll:UpdateScrollChildRect()
    end
    CompendiumView.UpdateVisibleRows(card)
end

function CompendiumView.CreateCard(parent, helpers)
    helpers = helpers or {}
    local card = createSectionFrame(parent, helpers, "card")
    card.helpers = helpers
    card:Hide()

    card.summaryPanel = createSectionFrame(card, helpers, "cardInset")
    card.summaryPanel:SetPoint("TOPLEFT", 14, -14)
    card.summaryPanel:SetPoint("TOPRIGHT", -14, -14)
    card.summaryPanel:SetHeight(52)

    card.statTotal = createStatLabel(card.summaryPanel)
    card.statMissing = createStatLabel(card.summaryPanel, card.statTotal)
    card.statOwned = createStatLabel(card.summaryPanel, card.statMissing)
    card.statWaypoints = createStatLabel(card.summaryPanel, card.statOwned)

    card.sidebar = createSectionFrame(card, helpers, "cardInset")
    card.sidebar:SetPoint("TOPLEFT", 14, -78)
    card.sidebar:SetPoint("BOTTOMLEFT", 14, 14)
    card.sidebar:SetWidth(SIDEBAR_WIDTH)

    card.categoryTitle = createLabel(card.sidebar, "GameFontNormal", "LEFT")
    card.categoryTitle:SetPoint("TOPLEFT", 12, -12)
    card.categoryTitle:SetPoint("TOPRIGHT", -12, -12)
    card.categoryTitle:SetTextColor(GOLD[1], GOLD[2], GOLD[3], 1)
    card.categoryTitle:SetText(L.COMPENDIUM_FILTER_CATEGORIES or "Categories")

    card.categoryButtons = {}
    for index = 1, MAX_CATEGORY_BUTTONS do
        local button = createCategoryButton(card.sidebar, helpers)
        button:SetPoint("TOPLEFT", card.categoryTitle, "BOTTOMLEFT", 0, -12 - ((index - 1) * 42))
        button:SetPoint("TOPRIGHT", card.categoryTitle, "BOTTOMRIGHT", 0, -12 - ((index - 1) * 42))
        card.categoryButtons[index] = button
    end

    card.sidebarHint = createLabel(card.sidebar, "GameFontDisableSmall", "LEFT")
    card.sidebarHint:SetPoint("TOPLEFT", card.categoryButtons[#card.categoryButtons], "BOTTOMLEFT", 0, -14)
    card.sidebarHint:SetPoint("BOTTOMRIGHT", -12, 12)
    card.sidebarHint:SetJustifyV("TOP")
    card.sidebarHint:SetWordWrap(true)
    card.sidebarHint:SetText(L.COMPENDIUM_SIDEBAR_HINT or "Tip: left-click a row with coordinates to set a waypoint.")

    card.mainPanel = createSectionFrame(card, helpers, "cardInset")
    card.mainPanel:SetPoint("TOPLEFT", card.sidebar, "TOPRIGHT", 14, 0)
    card.mainPanel:SetPoint("BOTTOMRIGHT", -14, 14)

    card.toolbarAnchor = CreateFrame("Frame", nil, card.mainPanel)
    card.toolbarAnchor:SetSize(1, 26)
    card.toolbarAnchor:SetPoint("TOPLEFT", 14, -12)

    local statusConfigs = {
        { key = "missing", label = function() return L.COMPENDIUM_FILTER_STATUS_MISSING or "Missing" end, width = 78 },
        { key = "all", label = function() return L.COMPENDIUM_FILTER_STATUS_ALL or "All" end, width = 54 },
        { key = "collected", label = function() return L.COMPENDIUM_FILTER_STATUS_COLLECTED or "Owned" end, width = 72 },
        { key = "unknown", label = function() return L.COMPENDIUM_FILTER_STATUS_UNKNOWN or "Unknown" end, width = 82 },
    }
    card.statusButtons = {}
    local previousStatus
    for index, config in ipairs(statusConfigs) do
        local button = createMiniActionButton(card.mainPanel, helpers, config.label(), config.width)
        button.statusKey = config.key
        if index == 1 then
            button:SetPoint("LEFT", card.toolbarAnchor, "LEFT", 0, 0)
        else
            button:SetPoint("LEFT", previousStatus, "RIGHT", 6, 0)
        end
        button:SetScript("OnClick", function(self)
            local state = getCompendiumState()
            state.status = self.statusKey or "missing"
            CompendiumView.RefreshCard(card.ui, nil, card.helpers, true)
        end)
        card.statusButtons[index] = button
        previousStatus = button
    end

    card.refreshButton = createMiniActionButton(card.mainPanel, helpers, L.COMPENDIUM_REFRESH or "Refresh", 78)
    card.refreshButton:SetPoint("TOPRIGHT", -14, -14)
    card.refreshButton:SetScript("OnClick", function()
        local state = getCompendiumState()
        if state.category == "decorations" then
            tryLoadHousingCatalogAddOns()
        end
        runtimeCache = {}
        CompendiumView.RefreshCard(card.ui, nil, card.helpers, false)
    end)

    card.sourceButton = createMiniActionButton(card.mainPanel, helpers, "", 150)
    card.sourceButton:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    card.sourceButton:SetPoint("RIGHT", card.refreshButton, "LEFT", -8, 0)
    card.sourceButton:SetScript("OnClick", function(self, button)
        local state = getCompendiumState()
        state.source = cycleOption(self.vlOptions or {}, state.source, button == "RightButton" and -1 or 1)
        CompendiumView.RefreshCard(card.ui, nil, card.helpers, true)
    end)
    card.sourceButton:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_CURSOR_RIGHT")
        GameTooltip:AddLine(L.COMPENDIUM_FILTER_SOURCE_TITLE or "Source filter", GOLD[1], GOLD[2], GOLD[3], true)
        GameTooltip:AddLine(L.COMPENDIUM_FILTER_SOURCE_HINT or "Left-click cycles forward, right-click cycles backward.", 0.86, 0.86, 0.82, true)
        GameTooltip:Show()
    end)
    card.sourceButton:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    card.professionButton = createMiniActionButton(card.mainPanel, helpers, "", 170)
    card.professionButton:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    card.professionButton:SetPoint("RIGHT", card.sourceButton, "LEFT", -8, 0)
    card.professionButton:SetScript("OnClick", function(self, button)
        local state = getCompendiumState()
        state.profession = cycleOption(self.vlOptions or {}, state.profession, button == "RightButton" and -1 or 1)
        CompendiumView.RefreshCard(card.ui, nil, card.helpers, true)
    end)

    card.header = CreateFrame("Frame", nil, card.mainPanel)
    card.header:SetPoint("TOPLEFT", card.toolbarAnchor, "BOTTOMLEFT", 0, -12)
    card.header:SetPoint("TOPRIGHT", -14, -12)
    card.header:SetHeight(22)
    card.headerName = createLabel(card.header, "GameFontDisableSmall", "LEFT")
    card.headerName:SetPoint("LEFT", 14, 0)
    card.headerName:SetText(L.COMPENDIUM_HEADER_NAME or "Entry")
    card.headerSource = createLabel(card.header, "GameFontDisableSmall", "LEFT")
    card.headerSource:SetPoint("LEFT", card.headerName, "RIGHT", 360, 0)
    card.headerSource:SetText(L.COMPENDIUM_HEADER_SOURCE or "Source")
    card.headerSource:Hide()
    card.headerStatus = createLabel(card.header, "GameFontDisableSmall", "RIGHT")
    card.headerStatus:SetPoint("RIGHT", -132, 0)
    card.headerStatus:SetText(L.COMPENDIUM_HEADER_STATUS or "Status")
    card.headerStatus:Hide()

    card.scroll = CreateFrame("ScrollFrame", nil, card.mainPanel, "UIPanelScrollFrameTemplate")
    card.scroll:SetPoint("TOPLEFT", card.header, "BOTTOMLEFT", 0, -6)
    card.scroll:SetPoint("BOTTOMRIGHT", -34, 12)

    card.scrollChild = CreateFrame("Frame", nil, card.scroll)
    card.scrollChild:SetSize(10, 10)
    card.scroll:SetScrollChild(card.scrollChild)
    callHelper(helpers, "styleScrollFrame", card.scroll)
    card.scroll:EnableMouseWheel(true)
    card.scroll:SetScript("OnMouseWheel", function(self, delta)
        if type(self.UpdateScrollChildRect) == "function" then
            self:UpdateScrollChildRect()
        end
        local range = tonumber(self:GetVerticalScrollRange()) or 0
        local current = tonumber(self:GetVerticalScroll()) or 0
        local target = clamp(current - ((tonumber(delta) or 0) * ROW_STEP * 3), 0, math.max(0, range))
        self:SetVerticalScroll(target)
        if self.ScrollBar and self.ScrollBar.SetValue then
            self.ScrollBar:SetValue(target)
        end
        CompendiumView.UpdateVisibleRows(card)
    end)
    card.scroll:SetScript("OnVerticalScroll", function()
        CompendiumView.UpdateVisibleRows(card)
    end)

    card.empty = createLabel(card.mainPanel, "GameFontDisableLarge", "LEFT")
    card.empty:SetPoint("TOPLEFT", card.scroll, "TOPLEFT", 10, -10)
    card.empty:SetPoint("BOTTOMRIGHT", card.scroll, "BOTTOMRIGHT", -10, 10)
    card.empty:SetJustifyV("TOP")
    card.empty:SetWordWrap(true)
    card.empty:SetText(L.COMPENDIUM_EMPTY or "No entries match the current filters.")
    card.empty:Hide()

    card:SetScript("OnSizeChanged", function()
        CompendiumView.UpdateVisibleRows(card)
    end)

    return card
end

function CompendiumView.RefreshCard(ui, character, helpers, forceTop)
    if not ui or not ui.frame or not ui.frame.compendiumCard then
        return
    end

    local card = ui.frame.compendiumCard
    card.ui = ui
    card.helpers = helpers or card.helpers or {}
    local state = getCompendiumState()
    if state.category == "decorations" then
        tryLoadHousingCatalogAddOns()
    end
    local view = buildView()
    local stats = view.categoryStats.all or {}

    card.statTotal:SetText(string.format(L.COMPENDIUM_STAT_TOTAL or "Total: %d", tonumber(stats.total) or 0))
    card.statMissing:SetText(string.format(L.COMPENDIUM_STAT_MISSING or "Missing: %d", tonumber(stats.missing) or 0))
    card.statOwned:SetText(string.format(L.COMPENDIUM_STAT_OWNED or "Owned: %d", tonumber(stats.collected) or 0))
    card.statWaypoints:SetText(string.format(L.COMPENDIUM_STAT_WAYPOINTS or "Waypoints: %d", tonumber(view.waypointCount) or 0))

    refreshCategoryButtons(card, view)
    refreshToolbar(card, view)
    if card.sidebarHint then
        if view.state and view.state.category == "decorations" then
            card.sidebarHint:SetText(L.COMPENDIUM_DECORATION_CATALOG_HINT or "Decoration counts come from Blizzard's Housing catalog. If they stay unknown after login, open the Housing dashboard once, then click Refresh. Vaultloom saves known counts per character.")
        else
            card.sidebarHint:SetText(L.COMPENDIUM_SIDEBAR_HINT or "Tip: click the waypoint icon on an entry to place a Blizzard waypoint.")
        end
    end
    refreshList(card, view, forceTop == true)
end

function CompendiumView.ClearCache()
    runtimeCache = {}
end
