--[[
    Warband Nexus - Utilities
    Common helper functions to eliminate code duplication
]]

local ADDON_NAME, ns = ...

---@class Utilities
local Utilities = {}
ns.Utilities = Utilities

--============================================================================
-- CHARACTER IDENTIFICATION
-- Canonical persisted identity for subsidiary tables: GetCharacterStorageKey / ResolveCharacterRowKey / GetCanonicalCharacterKey.
-- Logged-in `db.global.characters` index: CharacterService:ResolveCharactersTableKey(addon); writes / tracking: GetCharactersTablePersistKey; subsidiary caches: ResolveSubsidiaryCharacterKey.
-- Live writes: CharacterService:ResolveCharactersTableKey(addon) or GetCharacterStorageKey(addon); messages/UI legacy keys: GetCanonicalCharacterKey.
--============================================================================

--- Split a stored "Name-Realm" key using only the **first** hyphen.
--- Realms may contain hyphens (e.g. Azjol-Nerub); player names do not use `-` in WoW.
--- Greedy patterns like "^(.+)%-(.+)$" mis-parse as Name=Player-Azjol, Realm=Nerub.
---@param charKey string|nil
---@return string|nil namePart Space-stripped name prefix
---@return string|nil realmPart Space-stripped realm suffix (may include hyphens, apostrophes)
function Utilities:SplitCharacterKey(charKey)
    if not charKey or charKey == "" then return nil, nil end
    if issecretvalue and issecretvalue(charKey) then return nil, nil end
    local name, realm = tostring(charKey):match("^([^-]+)%-(.+)$")
    if not name or not realm then return nil, nil end
    return name:gsub("%s+", ""), realm:gsub("%s+", "")
end

--- Get character key (Name-Realm format, normalized: no spaces).
--- Single source of truth for character identification in DB and services.
--- When realm is omitted, prefers GetNormalizedRealmName() — same as DataService / SaveCurrentCharacterData.
--- Using only GetRealmName() here caused /reload to resolve a different key than SavedVariables on some realms.
---@param name string|nil Player name (defaults to current player)
---@param realm string|nil Realm name (defaults to current realm)
---@return string Character key in "Name-Realm" format (e.g. "Superluminal-TwistingNether")
function Utilities:GetCharacterKey(name, realm)
    name = name or UnitName("player")
    if not name or (issecretvalue and issecretvalue(name)) then return nil end
    if not realm then
        local norm = GetNormalizedRealmName and GetNormalizedRealmName()
        if type(norm) == "string" and not (issecretvalue and issecretvalue(norm)) and norm ~= "" then
            realm = norm
        else
            realm = GetRealmName and GetRealmName() or ""
        end
    end
    if not realm or (issecretvalue and issecretvalue(realm)) then return nil end
    
    -- CRITICAL: Normalize key to prevent duplicates
    -- Remove spaces for consistent matching (e.g. "Twisting Nether" -> "TwistingNether")
    name = name:gsub("%s+", "")
    realm = realm:gsub("%s+", "")
    
    local key = name .. "-" .. realm
    return key
end

--- Canonical SavedVariables key for **current session** character rows and subsidiary storage (currency, PvE, items, …).
--- Prefer **player UnitGUID** when `SafeGuid("player")` returns a non-secret string (stable across renames).
--- Fallback: normalized `GetCharacterKey()` when GUID is unavailable or secret (Midnight secure contexts).
--- `addonOrNil` is reserved for callers that pass AceDB/addon; unused today (avoid coupling Utilities to AceDB).
--- Blizzard identity: two distinct characters never share one player GUID — merges key on guid equality only;
--- **do not** require name/realm to match for same-guid collapse (post-rename name drift is expected).
---@param addonOrNil any|nil Reserved (e.g. WarbandNexus); ignored.
---@return string|nil Storage key (guid string or Name-Realm)
function Utilities:GetCharacterStorageKey(addonOrNil)
    local _ = addonOrNil
    local g = self:SafeGuid("player")
    if g then return g end
    return self:GetCharacterKey()
end

--- Offline-safe storage key for an existing **character row** (`db.global.characters[*]` value).
--- Uses stored `guid` when present and non-secret; otherwise normalized name+realm (legacy / never-logged-in rows).
--- When `guid` is nil or secret, name+realm is the only stable tie-break — never merge two rows on name alone if both have distinct guids.
---@param charData table Character row
---@return string|nil
function Utilities:ResolveCharacterRowKey(charData)
    if type(charData) ~= "table" then return nil end
    local g = charData.guid
    if type(g) == "string" and g ~= "" and not (issecretvalue and issecretvalue(g)) then
        return g
    end
    return self:GetCharacterKey(charData.name, charData.realm)
end

--- Resolve any character key to the canonical **storage** key used for subsidiary tables (same rules as `ResolveCharacterRowKey`).
--- Use when passing a key from UI or stored data into services (currency, gear, PvE cache, etc.).
---@param charKey string Key from db.characters or UI (may be guid, Name-Realm, or spaced legacy)
---@return string Canonical storage key
function Utilities:GetCanonicalCharacterKey(charKey)
    if not charKey or charKey == "" then return charKey end
    -- Never return a secret string: callers often do `GetCanonicalCharacterKey(k) or k` and would
    -- reintroduce the secret for downstream strsplit / equality / table indexing (ADDON_ACTION_FORBIDDEN).
    if issecretvalue and issecretvalue(charKey) then return nil end
    local db = ns.WarbandNexus and ns.WarbandNexus.db and ns.WarbandNexus.db.global
    local chars = db and db.characters
    local charData = chars and chars[charKey]
    if type(charData) == "table" then
        return self:ResolveCharacterRowKey(charData)
    end
    -- Stale selector/prefs may hold a guid string not yet loaded as an index (or typo): resolve via row.guid.
    if type(charKey) == "string" and charKey:sub(1, 7) == "Player-" and chars then
        for _, row in pairs(chars) do
            if type(row) == "table" then
                local g = row.guid
                if type(g) == "string" and g ~= "" and g == charKey and not (issecretvalue and issecretvalue(g)) then
                    return self:ResolveCharacterRowKey(row)
                end
            end
        end
        return charKey
    end
    -- Live session: after guid migration, `Name-Realm` may not exist as a table index — map to storage key.
    local liveStore = self:GetCharacterStorageKey()
    if liveStore and chars and chars[liveStore] then
        local n, r = self:SplitCharacterKey(tostring(charKey))
        if n and r then
            local incomingLegacy = self:GetCharacterKey(n, r)
            local sessionLegacy = self:GetCharacterKey()
            if incomingLegacy and sessionLegacy and incomingLegacy == sessionLegacy then
                return liveStore
            end
        end
    end

    -- Offline / migrated SV: row index is guid but UI/prefs still pass Name-Realm — match saved name+realm to real storage key.
    local name, realm = self:SplitCharacterKey(tostring(charKey))
    if name and realm and chars then
        local want = self:GetCharacterKey(name, realm)
        if want then
            for _, row in pairs(chars) do
                if type(row) == "table" then
                    local rn, rr = row.name, row.realm
                    if rn and rr and not (issecretvalue and issecretvalue(rn)) and not (issecretvalue and issecretvalue(rr)) then
                        if self:GetCharacterKey(rn, rr) == want then
                            return self:ResolveCharacterRowKey(row)
                        end
                    end
                end
            end
        end
        return self:GetCharacterKey(name, realm)
    end

    return tostring(charKey):gsub("%s+", "")
end

--- Display-only: pretty-print API/normalized realm (e.g. "TwistingNether" -> "Twisting Nether").
--- Persisted `charData.realm` and keys stay as returned by GetNormalizedRealmName / UnitName — do not rewrite storage.
--- Inserts a space before each uppercase letter that follows a lowercase letter.
---@param realm string Realm name (possibly normalized / spaceless)
---@return string Display-friendly realm name with proper spacing
function Utilities:FormatRealmName(realm)
    if not realm or realm == "" then return "" end
    return realm:gsub("(%l)(%u)", "%1 %2")
end

--- Display label for the current logged-in character (collectible toasts / Recent obtains).
--- Uses the same realm normalization as GetCharacterKey; realm is shown with FormatRealmName spacing.
---@return string|nil "Name — Realm" style, or name-only, or nil if unavailable / secret
function Utilities:GetCurrentCharacterDisplayLabel()
    local name = UnitName("player")
    if not name or name == "" or (issecretvalue and issecretvalue(name)) then return nil end
    local realm
    if GetNormalizedRealmName then
        local norm = GetNormalizedRealmName()
        if type(norm) == "string" and norm ~= "" and not (issecretvalue and issecretvalue(norm)) then
            realm = norm
        end
    end
    if not realm or realm == "" then
        if GetRealmName then
            local r = GetRealmName()
            if type(r) == "string" and r ~= "" and not (issecretvalue and issecretvalue(r)) then
                realm = r
            end
        end
    end
    if realm and realm ~= "" then
        local pretty = self:FormatRealmName(realm)
        if pretty and pretty ~= "" then
            return name .. " — " .. pretty
        end
        return name .. " — " .. realm
    end
    return name
end

--============================================================================
-- MODULE CHECKS
--============================================================================

--- Check if a module is enabled in settings
--- Eliminates 20+ duplicate checks across codebase
---@param moduleName string Module name (e.g., "pve", "currencies")
---@return boolean Whether the module is enabled
function Utilities:IsModuleEnabled(moduleName)
    local db = ns.WarbandNexus and ns.WarbandNexus.db
    if not db or not db.profile then return false end
    
    local modules = db.profile.modulesEnabled
    if not modules then return true end
    
    return modules[moduleName] ~= false
end

--============================================================================
-- MIDNIGHT 12.0 SECRET-VALUE GUARDS
--============================================================================
-- WoW 12.0 (Midnight) can return "secret" values from secure APIs; using them
-- in string ops, comparisons, or as table keys causes ADDON_ACTION_FORBIDDEN.
-- Use these helpers before any such use. issecretvalue is nil pre-12.0.
--============================================================================

local issecretvalue = issecretvalue

--- Safe string for display/indexing. Returns fallback if value is nil or secret.
---@param val any Value that might be a string or secret
---@param fallback string|nil Fallback when secret/nil (default "")
---@return string Safe string to use
function Utilities:SafeString(val, fallback)
    if val == nil then return fallback or "" end
    if issecretvalue and issecretvalue(val) then return fallback or "" end
    return tostring(val)
end

--- Safe number for comparisons/math. Returns fallback if value is secret or not a number.
---@param val any Value that might be a number or secret
---@param fallback number|nil Fallback when secret/invalid (default nil)
---@return number|nil Safe number or fallback
function Utilities:SafeNumber(val, fallback)
    if val == nil then return fallback end
    if issecretvalue and issecretvalue(val) then return fallback end
    local n = tonumber(val)
    return (n ~= nil) and n or fallback
end

--- Safe boolean. Returns fallback if value is secret.
---@param val any Value that might be boolean or secret
---@param fallback boolean|nil Fallback when secret (default false)
---@return boolean
function Utilities:SafeBool(val, fallback)
    if val == nil then return fallback ~= nil and fallback or false end
    if issecretvalue and issecretvalue(val) then return fallback ~= nil and fallback or false end
    return not not val
end

--- Safe UnitGUID. Returns nil if GUID is secret (Midnight instanced content).
---@param unit string Unit token (e.g. "player", "target")
---@return string|nil GUID or nil if secret/unavailable
function Utilities:SafeGuid(unit)
    if not unit then return nil end
    local guid = UnitGUID(unit)
    if not guid then return nil end
    if issecretvalue and issecretvalue(guid) then return nil end
    return guid
end

--- Whether the current runtime has secret-value API (Midnight 12.0+).
---@return boolean
function Utilities:HasSecretValueAPI()
    return issecretvalue ~= nil
end

--- Check if a value is secret; safe to call (no string/table ops on val).
---@param val any
---@return boolean True if val is a secret value
function Utilities:IsSecretValue(val)
    if val == nil then return false end
    return issecretvalue and issecretvalue(val) or false
end

--- Lowercase for search/sort when the string is safe to touch (never call `:lower()` on secret values).
---@param s string|nil
---@return string
function Utilities:SafeLower(s)
    if not s or s == "" then return "" end
    if issecretvalue and issecretvalue(s) then return "" end
    return s:lower()
end

--- Current character bag gold in copper. GetMoney() may be secret (Midnight); uses SafeNumber.
---@param fallbackCopper number|nil When unavailable or secret (default 0)
---@return number
function Utilities:GetLiveCharacterMoneyCopper(fallbackCopper)
    fallbackCopper = tonumber(fallbackCopper) or 0
    if not GetMoney then return fallbackCopper end
    local n = self:SafeNumber(GetMoney(), fallbackCopper)
    return (type(n) == "number") and n or fallbackCopper
end

--============================================================================
-- GOLD/CURRENCY HELPERS
--============================================================================

--- Get character's total copper (calculated from gold/silver/copper breakdown)
--- Prevents SavedVariables 32-bit overflow by using breakdown format
---@param charData table Character data from SavedVariables
---@return number Total copper amount
function Utilities:GetCharTotalCopper(charData)
    if not charData then return 0 end
    
    -- New format: gold/silver/copper breakdown (to avoid 32-bit SavedVariables overflow)
    if charData.gold or charData.silver or charData.copper then
        local gold = charData.gold or 0
        local silver = charData.silver or 0
        local copper = charData.copper or 0
        return (gold * 10000) + (silver * 100) + copper
    end
    
    -- Legacy fallback: old totalCopper field (if migration hasn't run yet)
    if charData.totalCopper then
        return charData.totalCopper
    end
    
    return 0
end

--- Get warband bank's total copper (calculated from gold/silver/copper breakdown)
--- Prevents SavedVariables 32-bit overflow by using breakdown format
---@param addon table|nil The WarbandNexus addon instance (optional)
---@param warbandData table|nil Warband bank data from SavedVariables (optional)
---@return number Total copper amount
function Utilities:GetWarbandBankTotalCopper(addon, warbandData)
    if not warbandData and addon then
        warbandData = addon.db and addon.db.global and addon.db.global.warbandBank
    end
    if not warbandData then return 0 end
    
    -- New format: gold/silver/copper breakdown
    if warbandData.gold or warbandData.silver or warbandData.copper then
        local gold = warbandData.gold or 0
        local silver = warbandData.silver or 0
        local copper = warbandData.copper or 0
        return (gold * 10000) + (silver * 100) + copper
    end
    
    -- Legacy fallback: old totalCopper field
    if warbandData.totalCopper then
        return warbandData.totalCopper
    end
    
    return 0
end

--- Get Warband Bank money from C_Bank API (TWW 11.0+)
--- Simple wrapper for C_Bank.FetchDepositedMoney (read-only)
---@return number Account money in copper
function Utilities:GetWarbandBankMoney()
    -- TWW (11.0+) API for getting warband bank gold
    if C_Bank and C_Bank.FetchDepositedMoney then
        local accountMoney = C_Bank.FetchDepositedMoney(Enum.BankType.Account)
        return accountMoney or 0
    end
    return 0
end

--============================================================================
-- BANK/BAG HELPERS
--============================================================================

--- Check if a bag ID is a Warband bag
--- Uses the global WARBAND_BAGS table from namespace
---@param bagID number The bag ID to check
---@return boolean Whether the bag is a Warband bag
function Utilities:IsWarbandBag(bagID)
    local wbBags = ns.WARBAND_BAGS
    for bi = 1, #wbBags do
        local warbandBagID = wbBags[bi]
        if bagID == warbandBagID then
            return true
        end
    end
    return false
end

--- Check if Warband bank is currently open
--- Uses event-based tracking combined with bag access verification
--- NOTE: Requires WarbandNexus addon instance for state tracking
---@param addon table The WarbandNexus addon instance
---@return boolean Whether the Warband bank is open
function Utilities:IsWarbandBankOpen(addon)
    if not addon then return false end
    
    -- Primary method: Use tracked state from BANKFRAME events
    if addon.warbandBankIsOpen then
        return true
    end
    
    -- Secondary method: If bank event flag is set, verify we can access Warband bags
    if addon.bankIsOpen then
        local firstBagID = Enum.BagIndex.AccountBankTab_1
        if firstBagID then
            local numSlots = C_Container.GetContainerNumSlots(firstBagID)
            if numSlots and numSlots > 0 then
                -- We can access Warband bank, update flag
                addon.warbandBankIsOpen = true
                return true
            end
        end
    end
    
    -- Fallback: Direct bag access check (in case events were missed)
    local firstBagID = Enum.BagIndex.AccountBankTab_1
    if firstBagID then
        local numSlots = C_Container.GetContainerNumSlots(firstBagID)
        -- In TWW, purchased Warband Bank tabs have 98 slots
        -- Only return true if we also see the bank is truly accessible
        if numSlots and numSlots > 0 then
            addon.warbandBankIsOpen = true
            addon.bankIsOpen = true
            return true
        end
    end
    
    return false
end

--============================================================================
-- ITEM HELPERS
--============================================================================

--- Get display name for an item (handles caged pets)
--- Caged pets show "Pet Cage" in item name but have the real pet name in tooltip line 3
---@param itemID number The item ID
---@param itemName string The item name from cache
---@param classID number|nil The item class ID (17 = Battle Pet)
---@return string displayName The display name (pet name for caged pets, item name otherwise)
function Utilities:GetItemDisplayName(itemID, itemName, classID)
    -- If this is a caged pet (classID 17), try to get the pet name from tooltip
    if classID == 17 and itemID then
        local petName = self:GetPetNameFromTooltip(itemID)
        if petName then
            return petName
        end
    end
    
    -- Fallback: Use item name
    return itemName or ((ns.L and ns.L["UNKNOWN"]) or UNKNOWN or "Unknown")
end

--- Extract pet name from item tooltip (locale-independent)
--- Used for caged pets where item name is "Pet Cage" but tooltip has the real pet name
---@param itemID number The item ID
---@return string|nil petName The pet's name extracted from tooltip
function Utilities:GetPetNameFromTooltip(itemID)
    if not itemID then
        return nil
    end
    
    -- METHOD 1: Try C_PetJournal API first (most reliable)
    -- GetPetInfoByItemID returns: name, icon, petType, creatureID, sourceText, description,
    --   isWild, canBattle, tradeable, unique, obtainable, displayID, speciesID
    -- First return value is the pet name (string), speciesID is the 13th return.
    if C_PetJournal and C_PetJournal.GetPetInfoByItemID then
        local petName = C_PetJournal.GetPetInfoByItemID(itemID)
        if type(petName) == "string" and not (issecretvalue and issecretvalue(petName)) and petName ~= "" then
            return petName
        end
    end
    
    -- METHOD 2: Tooltip parsing (fallback)
    if not C_TooltipInfo then
        return nil
    end
    
    local tooltipData = C_TooltipInfo.GetItemByID(itemID)
    if not tooltipData then
        return nil
    end
    
    -- METHOD 2A: CHECK battlePetName FIELD (TWW 11.0+ feature!)
    -- Surface args to expose all fields
    if TooltipUtil and TooltipUtil.SurfaceArgs then
        TooltipUtil.SurfaceArgs(tooltipData)
    end
    
    -- Check if battlePetName field exists (TWW API)
    if tooltipData.battlePetName and not (issecretvalue and issecretvalue(tooltipData.battlePetName)) and tooltipData.battlePetName ~= "" then
        return tooltipData.battlePetName
    end
    
    -- METHOD 2B: FALLBACK TO LINE PARSING
    if not tooltipData.lines then
        return nil
    end
    
    -- Known bad patterns to skip
    local knownBadPatterns = {
        "^Battle Pet", "^BattlePet", "^Pet Cage", "^Kampfhaustier", "^Mascotte",
        "^Companion", "^Use:", "^Requires:", "Level %d", 
        "^Poor", "^Common", "^Uncommon", "^Rare", "^Epic", "^Legendary", "^%d+$",
    }
    
    -- Parse tooltip lines for pet name
    for i = 1, math.min(#tooltipData.lines, 8) do
        local line = tooltipData.lines[i]
        if line and line.leftText then
            local text = line.leftText
            if issecretvalue and issecretvalue(text) then
                -- Midnight 12.0+: secure tooltip lines can be secret values.
                -- Skip parsing to avoid ADDON_ACTION_FORBIDDEN from string ops.
                text = nil
            end
            if text then
            
            -- Clean color codes and formatting
            local cleanText = text:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""):gsub("|h", ""):gsub("|H", "")
            cleanText = cleanText:match("^%s*(.-)%s*$") or ""
            
            -- Check if this line is a valid pet name
            if #cleanText >= 3 and #cleanText <= 35 then
                local isBadLine = false
                
                -- Check against known bad patterns
                for pi = 1, #knownBadPatterns do
                    local pattern = knownBadPatterns[pi]
                    if cleanText:match(pattern) then
                        isBadLine = true
                        break
                    end
                end
                
                -- Additional checks: contains ":" or starts with digit
                if not isBadLine then
                    if cleanText:match(":") or cleanText:match("^%d") then
                        isBadLine = true
                    end
                end
                
                if not isBadLine then
                    return cleanText
                end
            end
            end
        end
    end

    return nil
end

--============================================================================
-- BAG UTILITIES
--============================================================================

-- GetBagFingerprint: REMOVED — Dead code, never called from any module.
-- ItemsCacheService uses hash-based change detection (GenerateItemHash) instead.
-- This function was iterating all bags 0-4 with GetContainerNumSlots + GetContainerItemInfo
-- per slot (~100+ API calls) and was redundant with the hash system.

--============================================================================
-- TIME FORMATTING
--============================================================================

--- Format time remaining until a given timestamp
--- @param resetTime number Unix timestamp of reset time
--- @return string Formatted time string (e.g., "2d 5h", "3h 45m", "30m")
function Utilities:FormatTimeUntilReset(resetTime)
    local diff = (resetTime or 0) - time()
    return self:FormatTimeCompact(diff)
end

--============================================================================
-- TIME FORMATTING
--============================================================================

---Format a number of seconds into a human-readable compact time string
---@param seconds number Seconds remaining
---@return string Formatted time string (e.g., "2d 5h", "3h 45m", "30m", "Now")
function Utilities:FormatTimeCompact(seconds)
    if not seconds or seconds <= 0 then return "Now" end
    
    local days = math.floor(seconds / 86400)
    local hours = math.floor((seconds % 86400) / 3600)
    local minutes = math.floor((seconds % 3600) / 60)
    
    if days > 0 then
        return string.format("%dd %dh", days, hours)
    elseif hours > 0 then
        return string.format("%dh %dm", hours, minutes)
    else
        return string.format("%dm", minutes)
    end
end

---Format a number of seconds into a human-readable played time string
---@param seconds number Total seconds played
---@return string Formatted time string (e.g., "363 Days 16 Hours 55 Minutes")
function Utilities:FormatPlayedTime(seconds)
    local L = ns.L
    local dayS   = (L and L["PLAYED_DAYS"])    or "Days"
    local dayL    = (L and L["PLAYED_DAY"])     or "Day"
    local hourS   = (L and L["PLAYED_HOURS"])   or "Hours"
    local hourL   = (L and L["PLAYED_HOUR"])    or "Hour"
    local minuteS = (L and L["PLAYED_MINUTES"]) or "Minutes"
    local minuteL = (L and L["PLAYED_MINUTE"])  or "Minute"

    if not seconds or seconds <= 0 then return "0 " .. minuteS end

    local days = math.floor(seconds / 86400)
    local hours = math.floor((seconds % 86400) / 3600)
    local minutes = math.floor((seconds % 3600) / 60)

    local parts = {}
    if days > 0 then
        parts[#parts + 1] = days .. " " .. ((days == 1) and dayL or dayS)
    end
    if hours > 0 then
        parts[#parts + 1] = hours .. " " .. ((hours == 1) and hourL or hourS)
    end
    if minutes > 0 or #parts == 0 then
        parts[#parts + 1] = minutes .. " " .. ((minutes == 1) and minuteL or minuteS)
    end

    return table.concat(parts, " ")
end

--============================================================================
-- ICON HELPERS
--============================================================================

---Check if a texture string is an atlas name (no path separators)
---@param texturePath string|number|nil The texture value to check
---@return boolean Whether the value is an atlas name
function Utilities:IsAtlasName(texturePath)
    if not texturePath or type(texturePath) == "number" then return false end
    if type(texturePath) ~= "string" then return false end
    return not texturePath:find("\\") and not texturePath:find("/")
end

--============================================================================
-- CURRENCY DISPLAY (season progress — Dawncrests; weekly + bank — Coffer Key Shards)
--============================================================================

local CC_CAP_OPEN = "|cff80ff80"
local CC_CAPPED   = "|cffff5959"
local CC_WHITE    = "|cffffffff"
local CC_MUTED    = "|cff888888"
local EM_DASH_CUR = "\226\128\148"

--- Coffer Key Shards: localized name match (currency ID changes between patches).
---@param currencyID number|nil unused (kept for call-site compatibility)
---@param name string|nil
---@return boolean
function Utilities:IsCofferKeyShardCurrency(currencyID, name)
    if not name or name == "" then
        return false
    end
    if issecretvalue and issecretvalue(name) then
        return false
    end
    local n = string.lower(tostring(name))
    return n:find("coffer", 1, true) ~= nil and n:find("shard", 1, true) ~= nil
end

--- Season-split Dawncrests: on-hand / season max; cap color from totalEarned vs seasonMax.
--- Coffer Key Shards: weekly earned / weekly max (inventory in parentheses); cap color from weekly earn vs cap.
--- Weekly-only cap (no seasonMax): Current / weekly cap with qty vs maxQuantity.
---@param cd table|nil WarbandNexus:GetCurrencyData result
---@return string
function Utilities.FormatCurrencySeasonProgressLine(cd)
    local fmtNum = ns.UI_FormatNumber or function(n) return tostring(n or 0) end
    if not cd then
        return CC_MUTED .. "0|r"
    end
    local qty = tonumber(cd.quantity) or 0
    local maxQ = tonumber(cd.maxQuantity) or 0
    local te = cd.totalEarned
    local sm = tonumber(cd.seasonMax) or 0

    if Utilities:IsCofferKeyShardCurrency(cd.currencyID, cd.name) then
        local teNum = tonumber(te)
        if maxQ > 0 then
            local numColor
            if teNum ~= nil then
                numColor = (teNum >= maxQ) and CC_CAPPED or CC_CAP_OPEN
            else
                numColor = CC_WHITE
            end
            return numColor .. fmtNum(teNum or 0) .. "|r " .. CC_MUTED .. "/ " .. fmtNum(maxQ)
                .. "|r " .. CC_MUTED .. "(" .. CC_WHITE .. fmtNum(qty) .. CC_MUTED .. ")|r"
        end
        if qty > 0 then
            return CC_WHITE .. fmtNum(qty) .. "|r"
        end
        return CC_MUTED .. EM_DASH_CUR .. "|r"
    end

    -- Dawncrests: 3-value display — bag · season-earned / season-cap (so user sees current AND room left).
    if sm > 0 then
        local teNum = tonumber(te)
        if teNum ~= nil and teNum ~= qty then
            local capped = teNum >= sm
            local progressColor = capped and CC_CAPPED or CC_CAP_OPEN
            return CC_WHITE .. fmtNum(qty) .. "|r " .. CC_MUTED .. "\194\183|r " ..
                progressColor .. fmtNum(teNum) .. "|r" ..
                CC_MUTED .. " / " .. fmtNum(sm) .. "|r"
        end
        local numColor
        if teNum ~= nil then
            numColor = (teNum >= sm) and CC_CAPPED or CC_CAP_OPEN
        else
            numColor = CC_WHITE
        end
        return numColor .. fmtNum(qty) .. "|r " .. CC_MUTED .. "/ " .. fmtNum(sm) .. "|r"
    end
    if maxQ > 0 then
        local isCapped = qty >= maxQ
        local numColor = isCapped and CC_CAPPED or CC_CAP_OPEN
        return numColor .. fmtNum(qty) .. "|r " .. CC_MUTED .. "/ " .. fmtNum(maxQ) .. "|r"
    end
    if qty > 0 then
        return CC_WHITE .. fmtNum(qty) .. "|r"
    end
    return CC_MUTED .. EM_DASH_CUR .. "|r"
end

--============================================================================
-- OPTIONAL BLIZZARD ADDON LOADING (C_AddOns — Midnight 12.0.5)
--============================================================================

--- Load an optional Blizzard addon via C_AddOns. Uses pcall; no-op while in combat lockdown.
--- Replaces scattered C_AddOns.LoadAddOn / legacy LoadAddOn branches across modules.
---@param addonName string
---@return boolean ok True if already loaded or LoadAddOn pcall succeeded
function Utilities:SafeLoadAddOn(addonName)
    if not addonName or addonName == "" then return false end
    if InCombatLockdown() then return false end
    if C_AddOns and C_AddOns.IsAddOnLoaded and C_AddOns.IsAddOnLoaded(addonName) then
        return true
    end
    if C_AddOns and C_AddOns.LoadAddOn then
        local ok = pcall(C_AddOns.LoadAddOn, addonName)
        return ok
    end
    return false
end

--- Whether another addon is loaded (C_AddOns only; Midnight 12.0.5).
---@param addonName string
---@return boolean
function Utilities:CheckAddOnLoaded(addonName)
    if not addonName or addonName == "" then return false end
    if C_AddOns and C_AddOns.IsAddOnLoaded then
        return C_AddOns.IsAddOnLoaded(addonName) == true
    end
    return false
end

--============================================================================
-- WOWHEAD URL
--============================================================================

local WOWHEAD_TYPE_MAP = {
    mount       = "spell",
    pet         = "npc",
    toy         = "item",
    achievement = "achievement",
    item        = "item",
    quest       = "quest",
    spell       = "spell",
    npc         = "npc",
    currency    = "currency",
    illusion    = "item",
    title       = "title",
}

---Build a Wowhead URL for a given collectible/entity type and ID.
---@param entityType string "mount"|"pet"|"toy"|"achievement"|"item"|"quest"|"spell"|"npc"|"currency"|"illusion"|"title"
---@param id number
---@return string|nil url
function Utilities:GetWowheadURL(entityType, id)
    if not entityType or not id or id <= 0 then return nil end
    local wowheadType = WOWHEAD_TYPE_MAP[entityType]
    if not wowheadType then return nil end
    return "https://www.wowhead.com/" .. wowheadType .. "=" .. id
end

--============================================================================
-- SAFE CALL + CHAT ERROR (user-visible; no BugGrabber dependency)
--============================================================================

--- Run `fn(...)` inside pcall. On failure, print a red-tinted line to chat (WarbandNexus:Print when available).
--- Use for optional diagnostics and non-critical paths; hot paths should keep local pcall without chat spam.
---@param contextLabel string Short ASCII tag (e.g. module name) for the chat prefix
---@param fn function
---@return boolean ok True if pcall succeeded
---@return any ... On success: all fn return values. On failure: the error object only (no unpack of successes).
function Utilities:SafeCallReportChat(contextLabel, fn, ...)
    local results = { pcall(fn, ...) }
    local ok = table.remove(results, 1)
    if ok then
        return true, unpack(results)
    end
    local err = results[1]
    local tag = (type(contextLabel) == "string" and contextLabel ~= "") and contextLabel or "WN"
    local body = tostring(err)
    local line = "|cffff6600[" .. tag .. "]|r " .. body .. "|r"
    local addon = ns.WarbandNexus
    if addon and addon.Print then
        addon:Print(line)
    else
        print("|cffff6600[Warband Nexus]|r " .. body .. "|r")
    end
    return false, err
end

--- EditBox / FontRegion GetText for user-facing strings: never treat secret values as normal strings.
--- Safe before :lower, gsub, tonumber, or concatenation (Midnight `issecretvalue`).
---@param edit table|nil Frame with GetText()
---@return string Plain string; "" if nil, non-string, or secret.
function Utilities:SafePlainStringFromEdit(edit)
    if not edit or not edit.GetText then return "" end
    local t = edit:GetText()
    if t == nil then return "" end
    if issecretvalue and issecretvalue(t) then return "" end
    if type(t) ~= "string" then return "" end
    return t
end

--============================================================================
-- EXPORT
--============================================================================

return Utilities
