local addonName, privateDB = ...
local HDG = _G.HousingDecorGuide

-- ============================================================================
-- DYE DATA MODULE
-- Caches dye data from C_DyeColor API at runtime (lazy init)
-- Dyeable item cache persisted to HDG_DB.dyeableItems across sessions
-- ============================================================================

HDG_DyeData = {}

local dyeCache = {}           -- dyeColorID → DyeColorDisplayInfo
local categoryCache = {}      -- dyeColorCategoryID → { name, sortOrder }
local dyesByCategory = {}     -- categoryID → { dyeColorID, ... } sorted by sortOrder
local sortedCategories = {}   -- { catID, ... } sorted
local itemIDToDye = {}        -- itemID → dyeColorID (reverse lookup)
local dyeableCache = {}       -- itemID → true/nil (positive-only cache, persisted)
local catOwnership = {}        -- categoryID → true/false (pre-computed)
local catSwatchColor = {}      -- categoryID → {r, g, b} (first dye's swatch color)
local dyesByColorGroup = {}   -- colorGroupName → { dyeColorID, ... }
local sortedColorGroups = {}  -- { groupName, ... } in display order
local initialized = false
local totalDyeCount = 0
local dyeableCacheDirty = false -- track if we need to save
local LoadCustomThemes          -- forward declaration (defined in CUSTOM DYE THEMES section)

local Log = privateDB.Log
local LOG_LEVEL = privateDB.LOG_LEVEL

-- Debounce timer for BAG_UPDATE ownership refresh
local ownershipTimer = nil
local OWNERSHIP_DEBOUNCE = 0.5

-- ============================================================================
-- PERSISTENCE: Load/Save dyeable item cache from HDG_DB
-- ============================================================================

local function LoadDyeableCache()
    if _G.HDG_DB and _G.HDG_DB.dyeableItems then
        for itemID, _ in pairs(_G.HDG_DB.dyeableItems) do
            dyeableCache[itemID] = true
        end
        if Log then Log(LOG_LEVEL.INFO, "HDG_DyeData: loaded %d cached dyeable items", HDG_DyeData.GetDyeableCacheCount()) end
    end
end

local function SaveDyeableCache()
    if not dyeableCacheDirty then return end
    if not _G.HDG_DB then _G.HDG_DB = {} end
    _G.HDG_DB.dyeableItems = {}
    for itemID, _ in pairs(dyeableCache) do
        _G.HDG_DB.dyeableItems[itemID] = true
    end
    dyeableCacheDirty = false
end

-- Load persisted dyeable cache early (before EnsureInitialized / C_DyeColor)
-- so Acquire tab's dyeable filter works immediately
function HDG_DyeData.LoadDyeableCache()
    LoadDyeableCache()
end

function HDG_DyeData.GetDyeableCacheCount()
    local n = 0
    for _ in pairs(dyeableCache) do n = n + 1 end
    return n
end

-- ============================================================================
-- COLOR GROUP CLASSIFICATION (RGB → hue-based color families)
-- ============================================================================

-- Display order for color group buttons
local COLOR_GROUP_ORDER = {
    "Red", "Orange", "Yellow", "Green", "Teal", "Blue", "Purple", "Pink", "Brown", "Grey", "Black", "White"
}

-- Representative RGB for each group (button tint + fallback swatch)
local COLOR_GROUP_RGB = {
    Red    = { r = 0.85, g = 0.15, b = 0.15 },
    Orange = { r = 0.90, g = 0.55, b = 0.10 },
    Yellow = { r = 0.90, g = 0.85, b = 0.15 },
    Green  = { r = 0.20, g = 0.75, b = 0.20 },
    Teal   = { r = 0.10, g = 0.75, b = 0.70 },
    Blue   = { r = 0.20, g = 0.35, b = 0.85 },
    Purple = { r = 0.55, g = 0.20, b = 0.80 },
    Pink   = { r = 0.90, g = 0.40, b = 0.65 },
    Brown  = { r = 0.55, g = 0.35, b = 0.15 },
    Grey   = { r = 0.50, g = 0.50, b = 0.50 },
    Black  = { r = 0.15, g = 0.15, b = 0.15 },
    White  = { r = 0.90, g = 0.90, b = 0.90 },
}

local function RGBtoHSV(r, g, b)
    local max = math.max(r, g, b)
    local min = math.min(r, g, b)
    local delta = max - min
    local h, s, v = 0, 0, max
    if max > 0 then s = delta / max end
    if delta > 0 then
        if max == r then
            h = 60 * (((g - b) / delta) % 6)
        elseif max == g then
            h = 60 * ((b - r) / delta + 2)
        else
            h = 60 * ((r - g) / delta + 4)
        end
    end
    if h < 0 then h = h + 360 end
    return h, s, v
end

local function ClassifyColor(r, g, b)
    local h, s, v = RGBtoHSV(r, g, b)
    if v < 0.20 then return "Black" end
    if s < 0.12 and v > 0.70 then return "White" end
    if s < 0.15 then return "Grey" end
    if h >= 10 and h < 45 and v < 0.55 then return "Brown" end
    if h < 15 or h >= 340 then return "Red" end
    if h < 40 then return "Orange" end
    if h < 70 then return "Yellow" end
    if h < 165 then return "Green" end
    if h < 200 then return "Teal" end
    if h < 265 then return "Blue" end
    if h < 300 then return "Purple" end
    return "Pink"
end

-- ============================================================================
-- THEMED GROUPS (curated palettes mapped to color groups)
-- ============================================================================

local THEMED_GROUPS = {
    { name = "Alliance",  desc = "For the Alliance! Stormwind blue, silver and gold",  rgb = { r = 0.20, g = 0.35, b = 0.85 },
      items = { 259115, 259129, 259135, 259146, -- Alliance Blue, Zephras Blue, Midnight Blue, Nazjatar Navy
                259120, 259099, 259149, 259110, -- Bone-White, Highland Birch, Highborne Marble, Kul Tiran Steel (silver)
                258838, 259106 } }, -- Gold, Zandalari Gold
    { name = "Horde",     desc = "Lok'tar ogar! Blood red, black iron and worn leather", rgb = { r = 0.85, g = 0.15, b = 0.15 },
      items = { 259113, 259127, 259142, 259151, -- Horde Red, Firebloom Red, Ratchet Rust, Deep Mageroyal Red
                259109, 259121, 259111, -- Dark Iron, Obsidium Black, Ironclaw (black iron)
                259053, 259096, 259103 } }, -- Warm Teak, Mesquite Brown, Heartwood (worn leather)
    { name = "Forest",    desc = "Ancient groves, dappled sunlight through canopy",    rgb = { r = 0.20, g = 0.55, b = 0.15 },
      items = { 259114, 259134, 259124, 259147, 259150, 259133, -- Lush Green, Emerald Dreaming, Silversage, Grizzly Hills, Earthroot, Dustwallow
                259053, 259103, 259145, -- Warm Teak, Heartwood, Timbermaw Brown (bark)
                259137, 259100 } }, -- Sungrass Yellow, Holy Oak Tan (dappled sunlight)
    { name = "Ocean",     desc = "Abyssal depths, coral reefs and rolling tides",      rgb = { r = 0.10, g = 0.55, b = 0.70 },
      items = { 259115, 259129, 259135, 259146, -- Alliance Blue, Zephras Blue, Midnight Blue, Nazjatar Navy
                259136, 259148, 259125, 259110, -- Vortex Teal, Tidesage Teal, Un'Goro Green, Kul Tiran Steel
                259134, 259124, 259153 } }, -- Emerald Dreaming, Silversage Green, Dusk Lily Grey (seafoam)
    { name = "Fire",      desc = "Dragon's breath, molten ember and liquid gold",      rgb = { r = 0.90, g = 0.40, b = 0.10 },
      items = { 259127, 259113, 259154, 259142, -- Firebloom Red, Horde Red, Rain Poppy Red, Ratchet Rust
                259118, 259105, 259132, -- Elwynn Pumpkin, Copper, Kodohide Brown (ember)
                258838, 259117, 259137, 259138 } }, -- Gold, Sandfury Yellow, Sungrass Yellow, Savannah Gold (liquid gold)
    { name = "Shadow",    desc = "The Void beckons — darkness, ash and twilight",      rgb = { r = 0.35, g = 0.10, b = 0.50 },
      items = { 259126, 259144, 259140, 259131, -- Void Violet, Forsaken Plum, Moonberry Amethyst, Arcwine
                259109, 259121, 259098, -- Dark Iron, Obsidium Black, Darkwood (darkness)
                259123, 259153, 259104, -- Stormheim Grey, Dusk Lily Grey, Stormsteel (ash)
                259135 } }, -- Midnight Blue (twilight)
    { name = "Royal",     desc = "A throne room of imperial purple, crimson and gold", rgb = { r = 0.55, g = 0.20, b = 0.80 },
      items = { 259116, 259130, 259131, 259140, 259119, -- Kirin Tor Violet, Nightsong Lilac, Arcwine, Moonberry Amethyst, Netherstorm Fuchsia
                258838, 259106, 259107, -- Gold, Zandalari Gold, Brass
                259151, 259139 } }, -- Deep Mageroyal Red, Gilnean Rose (crimson)
    { name = "Pastel",    desc = "Spring blossoms, morning mist and soft petals",      rgb = { r = 0.80, g = 0.60, b = 0.70 },
      items = { 259139, 259119, 259130, -- Gilnean Rose, Netherstorm Fuchsia, Nightsong Lilac (blossoms)
                259120, 259078, 259099, 259149, -- Bone-White, Basic Birch, Highland Birch, Highborne Marble (mist)
                259148, 259125, -- Tidesage Teal, Un'Goro Green (soft teal)
                259101, 259128 } }, -- Pale Umber, Kalimdor Sand (soft tones)
    { name = "Earth",     desc = "Sun-baked clay, weathered stone and desert sand",    rgb = { r = 0.55, g = 0.40, b = 0.25 },
      items = { 259053, 259096, 259101, 259103, 259122, 259141, 259145, 259128, -- All browns (clay/sand)
                259105, 259132, -- Copper, Kodohide Brown (baked clay)
                259123, 259104 } }, -- Stormheim Grey, Stormsteel (weathered stone)
    { name = "Mono",      desc = "Timeless elegance — black, grey and white",          rgb = { r = 0.50, g = 0.50, b = 0.50 },
      items = { 259109, 259121, 259098, 259111, -- Dark Iron, Obsidium Black, Darkwood, Ironclaw (black)
                259123, 259104, 259153, 259110, -- Stormheim Grey, Stormsteel, Dusk Lily Grey, Kul Tiran Steel (grey)
                259120, 259078, 259099, 259149 } }, -- Bone-White, Basic Birch, Highland Birch, Highborne Marble (white)
}

-- Curated class fantasy dye lists (by itemID — resolved to dyeColorID at init)
-- Each list hand-picked to match the class aesthetic across specs and lore
local CLASS_THEMES = {
    { name = "DK",      desc = "Blood, frost and unholy death",         class = "DEATHKNIGHT",  rgb = { r = 0.77, g = 0.12, b = 0.23 },
      items = { 259113, 259127, 259152, 259154, 259151, -- Horde Red, Firebloom Red, Hinterlands Hickory, Rain Poppy, Deep Mageroyal (blood)
                259109, 259121, 259111, -- Dark Iron, Obsidium Black, Ironclaw (death/unholy)
                259135, 259146, 259110 } }, -- Midnight Blue, Nazjatar Navy, Kul Tiran Steel (frost)
    { name = "DH",      desc = "Fel fire, demonic shadow and chaos",    class = "DEMONHUNTER",  rgb = { r = 0.64, g = 0.19, b = 0.79 },
      items = { 259126, 259144, 259140, 259119, -- Void Violet, Forsaken Plum, Moonberry Amethyst, Netherstorm Fuchsia
                259114, 259134, 259133, -- Lush Green, Emerald Dreaming, Dustwallow Green (fel)
                259109, 259121 } }, -- Dark Iron, Obsidium Black (shadow)
    { name = "Druid",   desc = "Wild growth, bark and autumn leaves",   class = "DRUID",        rgb = { r = 1.00, g = 0.49, b = 0.04 },
      items = { 259118, 259105, 259108, 259132, -- Elwynn Pumpkin, Copper, Bronze, Kodohide Brown (orange/autumn)
                259114, 259134, 259150, 259133, -- Lush Green, Emerald Dreaming, Earthroot, Dustwallow Green (nature)
                259053, 259103, 259096 } }, -- Warm Teak, Heartwood, Mesquite Brown (bark)
    { name = "Evoker",  desc = "Draconic teal, jade and bronze",       class = "EVOKER",       rgb = { r = 0.20, g = 0.58, b = 0.50 },
      items = { 259136, 259148, 259125, 259110, -- Vortex Teal, Tidesage Teal, Un'Goro Green, Kul Tiran Steel
                259134, 259124, 259114, -- Emerald Dreaming, Silversage Green, Lush Green
                259108, 259105 } }, -- Bronze, Copper (bronze dragonflight)
    { name = "Hunter",  desc = "Wilderness, leather and camouflage",    class = "HUNTER",       rgb = { r = 0.67, g = 0.83, b = 0.45 },
      items = { 259114, 259133, 259147, 259124, 259150, -- Lush Green, Dustwallow, Grizzly Hills, Silversage, Earthroot
                259053, 259096, 259103, 259145, -- Warm Teak, Mesquite Brown, Heartwood, Timbermaw (leather)
                259128 } }, -- Kalimdor Sand
    { name = "Mage",    desc = "Arcane purple, frost blue and fire",    class = "MAGE",         rgb = { r = 0.25, g = 0.78, b = 0.92 },
      items = { 259136, 259148, 259129, 259115, -- Vortex Teal, Tidesage Teal, Zephras Blue, Alliance Blue (frost)
                259116, 259130, 259131, -- Kirin Tor Violet, Nightsong Lilac, Arcwine (arcane)
                259127, 259118 } }, -- Firebloom Red, Elwynn Pumpkin (fire)
    { name = "Monk",    desc = "Jade mist, bamboo and serenity",       class = "MONK",         rgb = { r = 0.00, g = 1.00, b = 0.60 },
      items = { 259134, 259114, 259124, 259150, -- Emerald Dreaming, Lush Green, Silversage Green, Earthroot
                259136, 259125, 259148, -- Vortex Teal, Un'Goro Green, Tidesage Teal
                259053, 259078 } }, -- Warm Teak, Basic Birch (bamboo/zen)
    { name = "Paladin", desc = "Holy gold, divine rose and silver",     class = "PALADIN",      rgb = { r = 0.96, g = 0.55, b = 0.73 },
      items = { 259139, 259119, -- Gilnean Rose, Netherstorm Fuchsia (rose/pink)
                258838, 259106, 259117, 259138, -- Gold, Zandalari Gold, Sandfury Yellow, Savannah Gold (holy light)
                259120, 259149, 259078 } }, -- Bone-White, Highborne Marble, Basic Birch (silver/purity)
    { name = "Priest",  desc = "Sacred light, purity and shadow",       class = "PRIEST",       rgb = { r = 1.00, g = 1.00, b = 1.00 },
      items = { 259120, 259078, 259099, 259149, -- Bone-White, Basic Birch, Highland Birch, Highborne Marble (holy)
                258838, 259106, 259117, -- Gold, Zandalari Gold, Sandfury Yellow (divine light)
                259126, 259144 } }, -- Void Violet, Forsaken Plum (shadow)
    { name = "Rogue",   desc = "Poison, shadow and gilded blades",      class = "ROGUE",        rgb = { r = 1.00, g = 0.96, b = 0.41 },
      items = { 258838, 259107, 259112, -- Gold, Brass, Dark Gold (gilded)
                259109, 259121, 259111, 259104, -- Dark Iron, Obsidium Black, Ironclaw, Stormsteel (shadow)
                259143, 259133 } }, -- Gravemoss Green, Dustwallow Green (poison)
    { name = "Shaman",  desc = "Storm, lava, earth and water",         class = "SHAMAN",       rgb = { r = 0.00, g = 0.44, b = 0.87 },
      items = { 259129, 259115, 259135, -- Zephras Blue, Alliance Blue, Midnight Blue (water/storm)
                259127, 259118, 259105, -- Firebloom Red, Elwynn Pumpkin, Copper (fire/lava)
                259122, 259096, 259128, -- Earthen Brown, Mesquite Brown, Kalimdor Sand (earth)
                259136 } }, -- Vortex Teal (water)
    { name = "Lock",    desc = "Demonic shadow, fel and brimstone",     class = "WARLOCK",      rgb = { r = 0.53, g = 0.53, b = 0.93 },
      items = { 259126, 259144, 259140, 259116, -- Void Violet, Forsaken Plum, Moonberry Amethyst, Kirin Tor Violet
                259134, 259143, -- Emerald Dreaming, Gravemoss Green (fel)
                259109, 259121, -- Dark Iron, Obsidium Black (shadow)
                259127 } }, -- Firebloom Red (brimstone)
    { name = "Warrior", desc = "Iron, blood and battle-worn leather",   class = "WARRIOR",      rgb = { r = 0.78, g = 0.61, b = 0.43 },
      items = { 259109, 259111, 259104, 259110, -- Dark Iron, Ironclaw, Stormsteel, Kul Tiran Steel (iron/arms)
                259113, 259127, 259142, -- Horde Red, Firebloom Red, Ratchet Rust (blood/fury)
                259053, 259096, 259103 } }, -- Warm Teak, Mesquite Brown, Heartwood (leather)
}

local dyesByTheme = {}     -- themeName → { dyeColorID, ... }
local sortedThemes = {}    -- { {name, rgb}, ... }

local function BuildThemedGroups()
    wipe(dyesByTheme)
    wipe(sortedThemes)

    -- Resolve curated itemID lists → dyeColorIDs for both themed palettes and class themes
    local allDefs = {}
    for _, def in ipairs(THEMED_GROUPS) do table.insert(allDefs, def) end
    for _, def in ipairs(CLASS_THEMES) do table.insert(allDefs, def) end
    for _, def in ipairs(allDefs) do
        dyesByTheme[def.name] = {}
        if def.items then
            for _, itemID in ipairs(def.items) do
                local dyeID = itemIDToDye[itemID]
                if dyeID then
                    table.insert(dyesByTheme[def.name], dyeID)
                end
            end
        end
        table.insert(sortedThemes, { name = def.name, rgb = def.rgb, desc = def.desc, class = def.class })
    end
end

-- ============================================================================
-- INITIALIZATION
-- ============================================================================

function HDG_DyeData.EnsureInitialized()
    if initialized then return true end
    -- If VamooseDyeStudio is loaded, it owns dye data — defer to VDS_DyeData
    if _G.VDS_DyeData and _G.VDS_DyeData ~= HDG_DyeData then return true end
    if not C_DyeColor then return false end

    -- Load persisted dyeable cache first (available before API init)
    LoadDyeableCache()

    local categories = C_DyeColor.GetAllDyeColorCategories()
    if not categories then return false end

    totalDyeCount = 0

    for _, catID in ipairs(categories) do
        local catInfo = C_DyeColor.GetDyeColorCategoryInfo(catID)
        if catInfo then
            categoryCache[catID] = {
                name = catInfo.name or ("Category " .. catID),
                sortOrder = catID, -- categories have no sortOrder; use ID
            }
            table.insert(sortedCategories, catID)
        end

        local dyeIDs = C_DyeColor.GetDyeColorsInCategory(catID, false) -- false = all, not just owned
        if dyeIDs then
            dyesByCategory[catID] = {}
            for _, dyeID in ipairs(dyeIDs) do
                local dyeInfo = C_DyeColor.GetDyeColorInfo(dyeID)
                if dyeInfo then
                    dyeInfo._categoryName = categoryCache[catID] and categoryCache[catID].name
                    -- Classify by swatch color
                    if dyeInfo.swatchColorStart then
                        local c = dyeInfo.swatchColorStart
                        dyeInfo._colorGroup = ClassifyColor(c.r, c.g, c.b)
                    end
                    dyeCache[dyeID] = dyeInfo
                    table.insert(dyesByCategory[catID], dyeID)
                    totalDyeCount = totalDyeCount + 1
                    if dyeInfo.itemID then
                        itemIDToDye[dyeInfo.itemID] = dyeID
                    end
                end
            end
            -- Sort dyes within category by sortOrder
            table.sort(dyesByCategory[catID], function(a, b)
                local infoA = dyeCache[a]
                local infoB = dyeCache[b]
                return (infoA and infoA.sortOrder or 0) < (infoB and infoB.sortOrder or 0)
            end)
        end
    end

    -- Sort categories
    table.sort(sortedCategories, function(a, b)
        local ca = categoryCache[a]
        local cb = categoryCache[b]
        return (ca and ca.sortOrder or 0) < (cb and cb.sortOrder or 0)
    end)

    initialized = true

    -- Pre-compute category ownership and swatch colors
    HDG_DyeData.RebuildCategoryOwnership()

    -- Build color group index (only groups that have dyes)
    wipe(dyesByColorGroup)
    wipe(sortedColorGroups)
    for dyeID, info in pairs(dyeCache) do
        local group = info._colorGroup
        if group then
            if not dyesByColorGroup[group] then dyesByColorGroup[group] = {} end
            table.insert(dyesByColorGroup[group], dyeID)
        end
    end
    -- Sort dyes within each group by sortOrder
    for _, dyeIDs in pairs(dyesByColorGroup) do
        table.sort(dyeIDs, function(a, b)
            return (dyeCache[a] and dyeCache[a].sortOrder or 0) < (dyeCache[b] and dyeCache[b].sortOrder or 0)
        end)
    end
    -- Build sorted group list (only non-empty groups, in display order)
    for _, groupName in ipairs(COLOR_GROUP_ORDER) do
        if dyesByColorGroup[groupName] then
            table.insert(sortedColorGroups, groupName)
        end
    end

    -- Build themed palette groups
    BuildThemedGroups()

    -- Load custom themes from SavedVariables
    LoadCustomThemes()

    if Log then Log(LOG_LEVEL.INFO, "HDG_DyeData initialized: %d dyes, %d color groups, %d themes", totalDyeCount, #sortedColorGroups, #sortedThemes) end
    return true
end

-- Rebuild pre-computed category ownership + swatch color caches
function HDG_DyeData.RebuildCategoryOwnership()
    wipe(catOwnership)
    wipe(catSwatchColor)
    for catID, dyeIDs in pairs(dyesByCategory) do
        local hasOwned = false
        for _, dyeID in ipairs(dyeIDs) do
            local info = dyeCache[dyeID]
            if info and (info.numOwned or 0) > 0 then hasOwned = true; break end
        end
        catOwnership[catID] = hasOwned
        -- Cache first dye's swatch color for the category
        if dyeIDs[1] then
            local info = dyeCache[dyeIDs[1]]
            if info and info.swatchColorStart then
                local c = info.swatchColorStart
                catSwatchColor[catID] = { r = c.r, g = c.g, b = c.b }
            end
        end
    end
end

-- ============================================================================
-- PUBLIC API: Color Groups (HDG value-add classification by swatch RGB)
-- ============================================================================

function HDG_DyeData.GetColorGroups()
    HDG_DyeData.EnsureInitialized()
    return sortedColorGroups
end

function HDG_DyeData.GetDyesInColorGroup(groupName)
    HDG_DyeData.EnsureInitialized()
    return dyesByColorGroup[groupName] or {}
end

function HDG_DyeData.GetColorGroupRGB(groupName)
    return COLOR_GROUP_RGB[groupName]
end

function HDG_DyeData.GetDyeColorGroup(dyeColorID)
    local info = dyeCache[dyeColorID]
    return info and info._colorGroup
end

-- ============================================================================
-- PUBLIC API: Themed Palettes
-- ============================================================================

function HDG_DyeData.GetThemes()
    HDG_DyeData.EnsureInitialized()
    return sortedThemes
end

function HDG_DyeData.GetDyesInTheme(themeName)
    HDG_DyeData.EnsureInitialized()
    return dyesByTheme[themeName] or {}
end

function HDG_DyeData.GetThemeRGB(themeName)
    for _, t in ipairs(sortedThemes) do
        if t.name == themeName then return t.rgb end
    end
    return nil
end

function HDG_DyeData.GetThemeDesc(themeName)
    for _, t in ipairs(sortedThemes) do
        if t.name == themeName then return t.desc end
    end
    return nil
end

-- ============================================================================
-- PUBLIC API: Categories (Blizzard)

function HDG_DyeData.GetCategories()
    if not C_DyeColor then return {} end
    HDG_DyeData.EnsureInitialized()
    return sortedCategories
end

function HDG_DyeData.GetCategoryName(catID)
    if not C_DyeColor then return nil end
    HDG_DyeData.EnsureInitialized()
    local cat = categoryCache[catID]
    return cat and cat.name
end

function HDG_DyeData.GetDyesInCategory(catID)
    if not C_DyeColor then return {} end
    HDG_DyeData.EnsureInitialized()
    return dyesByCategory[catID] or {}
end

-- ============================================================================
-- PUBLIC API: Individual Dyes
-- ============================================================================

function HDG_DyeData.GetDyeInfo(dyeColorID)
    if not C_DyeColor then return nil end
    HDG_DyeData.EnsureInitialized()
    return dyeCache[dyeColorID]
end

function HDG_DyeData.GetDyeByItemID(itemID)
    if not C_DyeColor then return nil end
    HDG_DyeData.EnsureInitialized()
    local dyeID = itemIDToDye[itemID]
    return dyeID and dyeCache[dyeID] or nil
end

function HDG_DyeData.IsDyeOwned(dyeColorID)
    if not C_DyeColor then return false end
    HDG_DyeData.EnsureInitialized()
    local info = dyeCache[dyeColorID]
    return info and (info.numOwned or 0) > 0
end

function HDG_DyeData.GetDyeCount(dyeColorID)
    if not C_DyeColor then return 0 end
    HDG_DyeData.EnsureInitialized()
    local info = dyeCache[dyeColorID]
    return info and info.numOwned or 0
end

-- Pre-computed: does player own any dye in this category?
function HDG_DyeData.HasOwnedDyeInCategory(catID)
    return catOwnership[catID] or false
end

-- Pre-computed: swatch color {r,g,b} for category (first dye's color)
function HDG_DyeData.GetCategorySwatchColor(catID)
    return catSwatchColor[catID]
end

-- ============================================================================
-- PUBLIC API: Aggregate Counts
-- ============================================================================

function HDG_DyeData.GetTotalDyeCount()
    if not C_DyeColor then return 0 end
    HDG_DyeData.EnsureInitialized()
    return totalDyeCount
end

function HDG_DyeData.GetTotalDyesOwned()
    if not C_DyeColor then return 0 end
    HDG_DyeData.EnsureInitialized()
    local owned = 0
    for _, info in pairs(dyeCache) do
        if (info.numOwned or 0) > 0 then
            owned = owned + 1
        end
    end
    return owned
end

-- ============================================================================
-- PUBLIC API: Dyeable Items (persisted cache)
-- ============================================================================

function HDG_DyeData.IsDecorDyeable(itemID)
    if not itemID then return false end
    if dyeableCache[itemID] then return true end
    if not C_HousingCatalog or not C_HousingCatalog.GetCatalogEntryInfoByItem then return false end

    local ok, decorInfo = pcall(C_HousingCatalog.GetCatalogEntryInfoByItem, itemID, true)
    if ok and decorInfo and decorInfo.canCustomize then
        dyeableCache[itemID] = true
        dyeableCacheDirty = true
        return true
    end
    return false
end

function HDG_DyeData.PreCacheDyeableItems(itemIDs)
    if not itemIDs or not C_HousingCatalog or not C_HousingCatalog.GetCatalogEntryInfoByItem then return end
    local newCount = 0
    for _, itemID in ipairs(itemIDs) do
        if not dyeableCache[itemID] then
            local ok, decorInfo = pcall(C_HousingCatalog.GetCatalogEntryInfoByItem, itemID, true)
            if ok and decorInfo and decorInfo.canCustomize then
                dyeableCache[itemID] = true
                newCount = newCount + 1
            end
        end
    end
    if newCount > 0 then
        dyeableCacheDirty = true
        if Log then Log(LOG_LEVEL.INFO, "HDG_DyeData: cached %d new dyeable items", newCount) end
    end
end

-- ============================================================================
-- OWNERSHIP REFRESH (on BAG_UPDATE, debounced)
-- ============================================================================

function HDG_DyeData.RefreshOwnership()
    if not C_DyeColor or not initialized then return end

    for dyeID, _ in pairs(dyeCache) do
        local info = C_DyeColor.GetDyeColorInfo(dyeID)
        if info then
            dyeCache[dyeID].numOwned = info.numOwned
        end
    end

    HDG_DyeData.RebuildCategoryOwnership()

    -- Fire internal event so Dye Panel can update
    if HDG and HDG.EventBus then
        HDG.EventBus:Trigger("HDG_DYE_OWNERSHIP_CHANGED")
    end
end

-- ============================================================================
-- CUSTOM DYE THEMES (user-created palettes, persisted in HDG_DB)
-- ============================================================================

local customThemes = {}        -- loaded from/saved to HDG_DB.customDyeThemes
local sortedCustomThemes = {}  -- rebuilt on every mutation

local function RebuildSortedCustomThemes()
    wipe(sortedCustomThemes)
    for i, theme in ipairs(customThemes) do
        sortedCustomThemes[i] = { name = theme.name, rgb = theme.rgb, desc = theme.desc or "" }
    end
end

LoadCustomThemes = function()
    if _G.HDG_DB and _G.HDG_DB.customDyeThemes then
        wipe(customThemes)
        for _, t in ipairs(_G.HDG_DB.customDyeThemes) do
            table.insert(customThemes, t)
        end
        RebuildSortedCustomThemes()
    end
end

local function SaveCustomThemes()
    if not _G.HDG_DB then _G.HDG_DB = {} end
    _G.HDG_DB.customDyeThemes = customThemes
end

-- Recalculate average swatch RGB for a custom theme
local function RecalcCustomThemeRGB(theme)
    if not theme.dyes or #theme.dyes == 0 then
        theme.rgb = { r = 0.4, g = 0.4, b = 0.4 }
        return
    end
    local rSum, gSum, bSum, n = 0, 0, 0, 0
    for _, dyeID in ipairs(theme.dyes) do
        local info = dyeCache[dyeID]
        if info and info.swatchColorStart then
            local c = info.swatchColorStart
            rSum = rSum + c.r; gSum = gSum + c.g; bSum = bSum + c.b
            n = n + 1
        end
    end
    if n > 0 then
        theme.rgb = { r = rSum / n, g = gSum / n, b = bSum / n }
    else
        theme.rgb = { r = 0.4, g = 0.4, b = 0.4 }
    end
end

function HDG_DyeData.GetCustomThemes()
    return sortedCustomThemes
end

function HDG_DyeData.GetCustomThemeDyes(name)
    for _, t in ipairs(customThemes) do
        if t.name == name then return t.dyes or {} end
    end
    return {}
end

function HDG_DyeData.GetCustomThemeByName(name)
    for i, t in ipairs(customThemes) do
        if t.name == name then return t, i end
    end
    return nil, nil
end

function HDG_DyeData.CreateCustomTheme(name)
    local FW = privateDB.CONFIG and privateDB.CONFIG.FLOATING_WIDGET
    local maxThemes = FW and FW.DYE_MAX_CUSTOM_THEMES or 20
    if #customThemes >= maxThemes then return nil end
    if not name then
        local idx = 1
        repeat
            name = "My Palette " .. idx
            idx = idx + 1
        until not HDG_DyeData.GetCustomThemeByName(name)
    end
    if HDG_DyeData.GetCustomThemeByName(name) then return nil end
    local theme = { name = name, rgb = { r = 0.4, g = 0.4, b = 0.4 }, desc = "", dyes = {} }
    table.insert(customThemes, theme)
    RebuildSortedCustomThemes()
    SaveCustomThemes()
    return theme
end

function HDG_DyeData.RenameCustomTheme(oldName, newName)
    if not newName or newName == "" then return false end
    if HDG_DyeData.GetCustomThemeByName(newName) then return false end
    local theme = HDG_DyeData.GetCustomThemeByName(oldName)
    if not theme then return false end
    theme.name = newName
    RebuildSortedCustomThemes()
    SaveCustomThemes()
    return true
end

function HDG_DyeData.DeleteCustomTheme(name)
    for i, t in ipairs(customThemes) do
        if t.name == name then
            table.remove(customThemes, i)
            RebuildSortedCustomThemes()
            SaveCustomThemes()
            return true
        end
    end
    return false
end

function HDG_DyeData.AddDyeToCustomTheme(name, dyeColorID)
    local theme = HDG_DyeData.GetCustomThemeByName(name)
    if not theme then return false end
    local FW = privateDB.CONFIG and privateDB.CONFIG.FLOATING_WIDGET
    local maxDyes = FW and FW.DYE_MAX_DYES_PER_THEME or 20
    if #theme.dyes >= maxDyes then return false end
    for _, id in ipairs(theme.dyes) do
        if id == dyeColorID then return false end -- already in
    end
    table.insert(theme.dyes, dyeColorID)
    RecalcCustomThemeRGB(theme)
    RebuildSortedCustomThemes()
    SaveCustomThemes()
    return true
end

function HDG_DyeData.RemoveDyeFromCustomTheme(name, dyeColorID)
    local theme = HDG_DyeData.GetCustomThemeByName(name)
    if not theme then return false end
    for i, id in ipairs(theme.dyes) do
        if id == dyeColorID then
            table.remove(theme.dyes, i)
            RecalcCustomThemeRGB(theme)
            RebuildSortedCustomThemes()
            SaveCustomThemes()
            return true
        end
    end
    return false
end

function HDG_DyeData.IsDyeInCustomTheme(name, dyeColorID)
    local theme = HDG_DyeData.GetCustomThemeByName(name)
    if not theme or not theme.dyes then return false end
    for _, id in ipairs(theme.dyes) do
        if id == dyeColorID then return true end
    end
    return false
end

-- ============================================================================
-- BASE64 ENCODE/DECODE (for export/import strings)
-- ============================================================================

local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local b64enc = {}
local b64dec = {}
for i = 1, 64 do
    local c = b64chars:sub(i, i)
    b64enc[i - 1] = c
    b64dec[c:byte()] = i - 1
end

local function Base64Encode(data)
    local out = {}
    local len = #data
    for i = 1, len, 3 do
        local a = data:byte(i)
        local b = i + 1 <= len and data:byte(i + 1) or 0
        local c = i + 2 <= len and data:byte(i + 2) or 0
        out[#out + 1] = b64enc[bit.rshift(a, 2)]
        out[#out + 1] = b64enc[bit.bor(bit.lshift(bit.band(a, 3), 4), bit.rshift(b, 4))]
        out[#out + 1] = (i + 1 <= len) and b64enc[bit.bor(bit.lshift(bit.band(b, 15), 2), bit.rshift(c, 6))] or "="
        out[#out + 1] = (i + 2 <= len) and b64enc[bit.band(c, 63)] or "="
    end
    return table.concat(out)
end

local function Base64Decode(data)
    local out = {}
    data = data:gsub("[^A-Za-z0-9+/=]", "")
    for i = 1, #data, 4 do
        local a = b64dec[data:byte(i)] or 0
        local b = b64dec[data:byte(i + 1)] or 0
        local c = b64dec[data:byte(i + 2)] or 0
        local d = b64dec[data:byte(i + 3)] or 0
        out[#out + 1] = string.char(bit.bor(bit.lshift(a, 2), bit.rshift(b, 4)))
        if data:sub(i + 2, i + 2) ~= "=" then
            out[#out + 1] = string.char(bit.band(bit.bor(bit.lshift(b, 4), bit.rshift(c, 2)), 0xFF))
        end
        if data:sub(i + 3, i + 3) ~= "=" then
            out[#out + 1] = string.char(bit.band(bit.bor(bit.lshift(c, 6), d), 0xFF))
        end
    end
    return table.concat(out)
end

-- ============================================================================
-- EXPORT / IMPORT CUSTOM THEMES
-- ============================================================================

function HDG_DyeData.ExportCustomTheme(name)
    local theme = HDG_DyeData.GetCustomThemeByName(name)
    if not theme or not theme.dyes or #theme.dyes == 0 then return nil end
    local ids = {}
    for _, id in ipairs(theme.dyes) do ids[#ids + 1] = tostring(id) end
    local payload = theme.name .. "|" .. table.concat(ids, ",")
    return "VDS:" .. Base64Encode(payload)
end

function HDG_DyeData.ImportCustomTheme(encodedStr)
    if not encodedStr or type(encodedStr) ~= "string" then return nil, "Invalid string" end
    encodedStr = strtrim(encodedStr)
    if encodedStr:sub(1, 4) ~= "VDS:" then return nil, "Not a Dye Studio theme string" end
    local b64 = encodedStr:sub(5)
    local ok, payload = pcall(Base64Decode, b64)
    if not ok or not payload or payload == "" then return nil, "Failed to decode" end
    local pipe = payload:find("|", 1, true)
    if not pipe then return nil, "Invalid format" end
    local name = payload:sub(1, pipe - 1)
    local idsStr = payload:sub(pipe + 1)
    if name == "" or idsStr == "" then return nil, "Empty name or dyes" end
    -- Validate dye IDs
    local dyes = {}
    for idStr in idsStr:gmatch("[^,]+") do
        local id = tonumber(idStr)
        if not id then return nil, "Invalid dye ID: " .. idStr end
        local info = dyeCache[id]
        if not info then return nil, "Unknown dye ID: " .. idStr end
        dyes[#dyes + 1] = id
    end
    if #dyes == 0 then return nil, "No valid dyes" end
    -- Deduplicate name if needed
    local baseName = name
    local idx = 1
    while HDG_DyeData.GetCustomThemeByName(name) do
        idx = idx + 1
        name = baseName .. " " .. idx
    end
    local FW = privateDB.CONFIG and privateDB.CONFIG.FLOATING_WIDGET
    local maxThemes = FW and FW.DYE_MAX_CUSTOM_THEMES or 20
    if #customThemes >= maxThemes then return nil, "Max custom themes reached" end
    local theme = { name = name, rgb = { r = 0.4, g = 0.4, b = 0.4 }, desc = "", dyes = dyes }
    RecalcCustomThemeRGB(theme)
    table.insert(customThemes, theme)
    RebuildSortedCustomThemes()
    SaveCustomThemes()
    return name
end

-- ============================================================================
-- EVENT HANDLING
-- ============================================================================

local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("BAG_UPDATE")
eventFrame:RegisterEvent("PLAYER_LOGOUT")
eventFrame:SetScript("OnEvent", function(self, event)
    if event == "BAG_UPDATE" and initialized then
        if ownershipTimer then ownershipTimer:Cancel() end
        ownershipTimer = C_Timer.NewTimer(OWNERSHIP_DEBOUNCE, function()
            ownershipTimer = nil
            HDG_DyeData.RefreshOwnership()
        end)
    elseif event == "PLAYER_LOGOUT" then
        SaveDyeableCache()
        SaveCustomThemes()
    end
end)
