--## Version: 4.1.0 ## Author: romdeau23 ## SavedVariables: MogPartialSetsAddonConfig ## SavedVariablesPerCharacter MogPartialSetsAddonProfile
---@class Addon
local MogPartialSets, gameEvent = {}, {}
MogPartialSets.gameEvent = gameEvent

local frame = CreateFrame('Frame')
---@type table<string, table<function, true>>
local listeners = {}

---@param eventName string
---@param callback function
function gameEvent.on(eventName, callback)
    if not listeners[eventName] then
        frame:RegisterEvent(eventName)
        listeners[eventName] = {}
    end

    listeners[eventName][callback] = true
end

---@param eventName string
---@param callback function
function gameEvent.off(eventName, callback)
    if listeners[eventName] then
        listeners[eventName][callback] = nil

        if next(listeners[eventName]) == nil then
            frame:UnregisterEvent(eventName)
            listeners[eventName] = nil
        end
    end
end

frame:SetScript('OnEvent', function (_, eventName, ...)
    local selfRemoved = false

    for listener in pairs(listeners[eventName]) do
        local success, result = pcall(listener, ...)

        if success then
            if result == false then
                listeners[eventName][listener] = nil
                selfRemoved = true
            end
        else
            CallErrorHandler(result)
        end
    end

    if selfRemoved and next(listeners[eventName]) == nil then
        frame:UnregisterEvent(eventName)
        listeners[eventName] = nil
    end
end)

-- convenience shortcut
MogPartialSets.on = gameEvent.on


---@class Addon
local loaded = false
---@type fun()[]
local initializers = {}

---@param initializer fun()
function MogPartialSets.registerInitializer(initializer)
    assert(not loaded)
    table.insert(initializers, initializer)
end

MogPartialSets.on('ADDON_LOADED', function (name)
    if name == "OrzUI" then
        loaded = true

        for _, initializer in ipairs(initializers) do
            local success, result = pcall(initializer)

            if not success then
                CallErrorHandler(result)
            end
        end

        return false
    end
end)


---@class Addon
local const = {}
MogPartialSets.const = const

---https://warcraft.wiki.gg/wiki/ClassId
---@type table<number, number> classId => mask
const.classMasks = {
    [1] = 2 ^ (1 - 1), -- WARRIOR
    [2] = 2 ^ (2 - 1), -- PALADIN
    [3] = 2 ^ (3 - 1), -- HUNTER
    [4] = 2 ^ (4 - 1), -- ROGUE
    [5] = 2 ^ (5 - 1), -- PRIEST
    [6] = 2 ^ (6 - 1), -- DEATHKNIGHT
    [7] = 2 ^ (7 - 1), -- SHAMAN
    [8] = 2 ^ (8 - 1), -- MAGE
    [9] = 2 ^ (9 - 1), -- WARLOCK
    [10] = 2 ^ (10 - 1), -- MONK
    [11] = 2 ^ (11 - 1), -- DRUID
    [12] = 2 ^ (12 - 1), -- DEMONHUNTER
    [13] = 2 ^ (13 - 1), -- EVOKER
}

---@type table<number, number> classId => mask
const.armorTypeClassMasks = {
    -- cloth
    [5] = bit.bor(const.classMasks[5], const.classMasks[8], const.classMasks[9]), -- PRIEST
    [8] = bit.bor(const.classMasks[5], const.classMasks[8], const.classMasks[9]), -- MAGE
    [9] = bit.bor(const.classMasks[5], const.classMasks[8], const.classMasks[9]), -- WARLOCK

    -- leather
    [4] = bit.bor(const.classMasks[4], const.classMasks[10], const.classMasks[11], const.classMasks[12]), -- ROGUE
    [10] = bit.bor(const.classMasks[4], const.classMasks[10], const.classMasks[11], const.classMasks[12]), -- MONK
    [11] = bit.bor(const.classMasks[4], const.classMasks[10], const.classMasks[11], const.classMasks[12]), -- DRUID
    [12] = bit.bor(const.classMasks[4], const.classMasks[10], const.classMasks[11], const.classMasks[12]), -- DEMONHUNTER

    -- mail
    [3] = bit.bor(const.classMasks[3], const.classMasks[7], const.classMasks[13]), -- HUNTER
    [7] = bit.bor(const.classMasks[3], const.classMasks[7], const.classMasks[13]), -- SHAMAN
    [13] = bit.bor(const.classMasks[3], const.classMasks[7], const.classMasks[13]), -- EVOKER

    -- plate
    [1] = bit.bor(const.classMasks[1], const.classMasks[2], const.classMasks[6]), -- WARRIOR
    [2] = bit.bor(const.classMasks[1], const.classMasks[2], const.classMasks[6]), -- PALADIN
    [6] = bit.bor(const.classMasks[1], const.classMasks[2], const.classMasks[6]), -- DEATHKNIGHT
}

---@type table<number, number> slot => itemId
const.hiddenItemMap = {
    [INVSLOT_HEAD] = 134110,
    [INVSLOT_SHOULDER] = 134112,
    [INVSLOT_BACK] = 134111,
    [INVSLOT_CHEST] = 168659,
    [INVSLOT_BODY] = 142503,
    [INVSLOT_TABARD] = 142504,
    [INVSLOT_WRIST] = 168665,
    [INVSLOT_HAND] = 158329,
    [INVSLOT_WAIST] = 143539,
    [INVSLOT_LEGS] = 216696,
    [INVSLOT_FEET] = 168664,
}

---@type table<number, string> slot => label
const.slotLabelMap = {
    [INVSLOT_HEAD] = HEADSLOT,
    [INVSLOT_SHOULDER] = SHOULDERSLOT,
    [INVSLOT_BACK] = BACKSLOT,
    [INVSLOT_CHEST] = CHESTSLOT,
    [INVSLOT_BODY] = SHIRTSLOT,
    [INVSLOT_TABARD] = TABARDSLOT,
    [INVSLOT_WRIST] = WRISTSLOT,
    [INVSLOT_HAND] = HANDSSLOT,
    [INVSLOT_WAIST] = WAISTSLOT,
    [INVSLOT_LEGS] = LEGSSLOT,
    [INVSLOT_FEET] = FEETSLOT,
}


---@generic T : function
---@param delay number seconds
---@param callback T
---@return T
function MogPartialSets.debounce(delay, callback)
    local timer

    return function (...)
        local args = {...}
        local argCount = select('#', ...)

        -- re-schedule timer
        if timer then
            timer:Cancel()
        end

        timer = C_Timer.NewTimer(delay, function ()
            timer = nil
            callback(unpack(args, 1, argCount))
        end)
    end
end


function MogPartialSets.createObservable()
    ---@class Observable
    local observable = {}
    ---@type table<string, table<function, true>>
    local listeners = {}

    ---@param eventName string
    ---@param callback function
    function observable.on(eventName, callback)
        if not listeners[eventName] then
            listeners[eventName] = {}
        end

        listeners[eventName][callback] = true
    end

    ---@param eventName string
    ---@param callback function
    function observable.off(eventName, callback)
        if listeners[eventName] then
            listeners[eventName][callback] = nil

            if next(listeners[eventName]) == nil then
                listeners[eventName] = nil
            end
        end
    end

    ---@param eventName string
    ---@param ... any
    function observable.dispatch(eventName, ...)
        if not listeners[eventName] then
            return
        end

        local selfRemoved = false

        for listener in pairs(listeners[eventName]) do
            local success, result = pcall(listener, ...)

            if success then
                if result == false then
                    listeners[eventName][listener] = nil
                    selfRemoved = true
                end
            else
                CallErrorHandler(result)
            end
        end

        if selfRemoved and next(listeners[eventName]) == nil then
            listeners[eventName] = nil
        end
    end

    return observable
end


MogPartialSets.event = MogPartialSets.createObservable()



MogPartialSetsAddon = MogPartialSets


---@type Module[]
local modules = {}

---@return table
function MogPartialSets.module()
    ---@class Module
    ---@field init fun()?
    local module = {}

    table.insert(modules, module)

    return module
end

MogPartialSets.registerInitializer(function ()
    for _, module in ipairs(modules) do
        if module.init then
            module.init()
        end
    end
end)


---@generic T
---@alias Packed<T> {n: integer, [integer]: T}

---@generic T
---@param ... T
---@return Packed<T>
function MogPartialSets.pack(...)
    return {n = select('#', ...), ...}
end


---@class QueueWorker.Task
---@field key any
---@field args Packed<any>
---@field next QueueWorker.Task?

---@param executor function should do the work, receives any extra args passed to add()
---@param updater fun() should do any related updates, called after a batch of tasks is done
---@param frameTimeLimit number max work duration per 1 frame (must be > 0)
---@param updaterFreq number max frequency of calling the updater (must be >= 0)
---@return QueueWorker
function MogPartialSets.createQueueWorker(executor, updater, frameTimeLimit, updaterFreq)
    assert(frameTimeLimit > 0)
    assert(updaterFreq >= 0)

    ---@class QueueWorker
    local worker = {}
    ---@type QueueWorker.Task?
    local nextTask
    ---@type QueueWorker.Task?
    local lastTask
    ---@type table<any, true>
    local pendingKeyMap = {}
    ---@type number
    local spentTime = 0
    ---@type number|nil
    local lastUpdaterCall
    ---@type boolean
    local updaterScheduled = false
    ---@type boolean
    local scheduled = false

    local execute, schedule, scheduleUpdater, runUpdater, processQueue

    ---@return Packed<any> result
    function execute(...)
        local startTime = GetTimePreciseSec()
        local result = MogPartialSets.pack(pcall(executor, ...))
        spentTime = spentTime + (GetTimePreciseSec() - startTime)

        return result
    end

    function schedule()
        C_Timer.After(0, processQueue)
        scheduled = true
    end

    function runUpdater()
        lastUpdaterCall = GetTimePreciseSec()
        updaterScheduled = false
        updater()
    end

    function scheduleUpdater()
        if updaterScheduled then
            return
        end

        local delay

        if not lastUpdaterCall then
            delay = updaterFreq
        else
            delay = math.max(0, updaterFreq - (GetTimePreciseSec() - lastUpdaterCall))
        end

        C_Timer.After(delay, runUpdater)
        updaterScheduled = true
    end

    function processQueue()
        spentTime = 0
        scheduled = false

        if not nextTask then
            return
        end

        while nextTask and spentTime < frameTimeLimit do
            local result = execute(unpack(nextTask.args, 1, nextTask.args.n))

            pendingKeyMap[nextTask.key] = nil
            nextTask = nextTask.next

            if not nextTask then
                lastTask = nil
            end

            if not result[1] then
                CallErrorHandler(result[2])
            end
        end

        scheduleUpdater()

        if nextTask and not scheduled then
            schedule()
        end
    end

    ---@return any ... executor's return value if it ran immediately, otherwise nothing
    function worker.add(key, ...)
        -- skip if already queued
        if pendingKeyMap[key] then
            return
        end

        -- always mark as pending (re-entrancy)
        pendingKeyMap[key] = true

        -- execute right away if possible
        if spentTime < frameTimeLimit then
            local result = execute(...)

            pendingKeyMap[key] = nil
            scheduleUpdater()

            if not result[1] then
                CallErrorHandler(result[2])
            end

            return unpack(result, 2, result.n)
        end

        -- otherwise queue
        local task = {key = key, args = MogPartialSets.pack(...)}

        if lastTask then
            lastTask.next = task
            lastTask = task
        else
            nextTask = task
            lastTask = task
        end

        if not scheduled then
            schedule()
        end
    end

    ---@return boolean
    function worker.busy()
        return nextTask ~= nil
    end

    ---@param limit number
    function worker.setFrameTimeLimit(limit)
        assert(limit > 0)
        frameTimeLimit = limit
    end

    ---@param freq number
    function worker.setUpdaterFreq(freq)
        assert(freq >= 0)
        updaterFreq = freq
    end

    return worker
end


---@class SavedVarTable
---@field version integer
---@field data table

---@generic TData
---@param name string
---@param version integer
---@param defaults TData
---@param migrations table<integer, fun(data: table)>
---@return TData
function MogPartialSets.loadSavedVar(name, version, defaults, migrations)
    ---@type SavedVarTable
    local var

    ---@return boolean
    local function ensureUpToDate()
        if type(var.version) ~= 'number' then
            -- invalid version
            return false
        end

        if var.data == nil then
            -- old structure, convert to new one before migration
            local currentVersion = var.version
            local data = {}

            for k, v in pairs(var) do
                if k ~= 'version' then
                    data[k] = v
                end
            end

            table.wipe(var)
            var.version = currentVersion
            var.data = data
        end

        if var.version < version then
            -- old version
            for to = var.version + 1, version do
                if migrations[to] then
                    migrations[to](var.data)
                end
            end

            var.version = version

            return true
        elseif var.version > version then
            -- newer version - MogPartialSets downgraded?
            return false
        end

        -- all is ok
        return true
    end

    -- init saved variable
    if type(_G[name]) == 'table' then
        var = _G[name]

        local success, result = pcall(ensureUpToDate)

        if not success or not result then
            -- reset var on crash or failure
            table.wipe(var)
            var.version = version
            var.data = CopyTable(defaults)
        end

        if not success then
            CallErrorHandler(result)
        end
    else
        var = {
            version = version,
            data = CopyTable(defaults)
        }

        _G[name] = var
    end

    return var.data
end


---@class Addon
local locale = MogPartialSets.module()
MogPartialSets.locale = locale

local translations = {}
local currentLocale = GetLocale()

---@param localeCode string the locale code (e.g., 'enUS', 'zhCN')
---@param strings table<string, string> the translation strings
function locale.register(localeCode, strings)
    translations[localeCode] = strings
end

---@param key string the translation key
---@return string # the localized string or the key if not found
function locale.get(key)
    return (
        translations[currentLocale] and translations[currentLocale][key]
        or translations['enUS'][key] -- fallback to enUS
        or key -- translation not found
    )
end

-- shortcut
MogPartialSets.L = locale.get

MogPartialSets.locale.register('enUS', {
    -- filter menu
    ['SHOW_EXTRA_SETS'] = 'Show extra sets',
    ['SHOW_EXTRA_SETS_TOOLTIP'] = 'Show all the sets you can use, not just your class sets.',
    ['SHOW_EXTRA_SETS_SUPPRESSED'] = 'Suppressed by PvE/PvP filter',
    ['ONLY_FAVORITES'] = 'Only favorites',
    ['INCLUDE_FAVORITE_VARIANTS'] = 'Include favorite variants',
    ['HIDE_ITEMS_NOT_IN_SET'] = 'Hide items not in set',
    ['HIDE_ITEMS_NOT_IN_SET_TOOLTIP'] = 'When applying a set, hide the other slots.',
    ['MAX_MISSING_PIECES'] = 'Max missing pieces',
    ['IGNORED_SLOTS'] = 'Ignored slots',
    ['IGNORED_SLOTS_TOOLTIP'] = 'These slots don\'t count towards max missing pieces.',
    ['SKIPPED_SLOTS'] = 'Skipped slots',
    ['SKIPPED_SLOTS_TOOLTIP'] = 'These slots are never changed.',
    ['HIDDEN_SLOTS'] = 'Hidden slots',
    ['HIDDEN_SLOTS_TOOLTIP'] = 'These slots are always hidden (and also don\'t count as missing).',
    ['USE_CHARACTER_PROFILE'] = 'Use character profile',
    ['USE_CHARACTER_PROFILE_TOOLTIP'] = 'Save the ignored, skipped and hidden slots per-character.',
    ['RELOAD_SETS'] = 'Reload sets',
    ['RESET'] = 'Reset',

    -- sets tab tooltip
    ['MISSING_SLOTS'] = '(Missing: %s)',
    ['SET_ID'] = '(Set ID: %d)',
    ['AND_MORE'] = ' and %d more',
})

MogPartialSets.locale.register('zhCN', {
    -- filter menu
    ['SHOW_EXTRA_SETS'] = '显示额外套装',
    ['SHOW_EXTRA_SETS_TOOLTIP'] = '显示所有你可以使用的套装，不仅限于本职业套装。',
    ['SHOW_EXTRA_SETS_SUPPRESSED'] = '已被PvE/PvP筛选抑制',
    ['ONLY_FAVORITES'] = '仅显示收藏',
    ['INCLUDE_FAVORITE_VARIANTS'] = '包含收藏的变体',
    ['HIDE_ITEMS_NOT_IN_SET'] = '隐藏套装外物品',
    ['HIDE_ITEMS_NOT_IN_SET_TOOLTIP'] = '应用套装时，隐藏其他部位。',
    ['MAX_MISSING_PIECES'] = '最大缺失件数',
    ['IGNORED_SLOTS'] = '忽略部位',
    ['IGNORED_SLOTS_TOOLTIP'] = '这些部位不计入最大缺失件数。',
    ['SKIPPED_SLOTS'] = '跳过部位',
    ['SKIPPED_SLOTS_TOOLTIP'] = '这些部位永远不会被改变。',
    ['HIDDEN_SLOTS'] = '隐藏部位',
    ['HIDDEN_SLOTS_TOOLTIP'] = '这些部位始终隐藏（并且也不计入缺失）。',
    ['USE_CHARACTER_PROFILE'] = '使用角色配置',
    ['USE_CHARACTER_PROFILE_TOOLTIP'] = '为每个角色单独保存忽略、跳过和隐藏的部位设置。',
    ['RELOAD_SETS'] = '重新加载套装',
    ['RESET'] = '重置',

    -- sets tab tooltip
    ['MISSING_SLOTS'] = '（缺少：%s）',
    ['SET_ID'] = '（套装 ID：%d）',
    ['AND_MORE'] = ' 等另外 %d 件',
})


MogPartialSets.locale.register('zhTW', {
    -- filter menu
    ['SHOW_EXTRA_SETS'] = '顯示額外套裝',
    ['SHOW_EXTRA_SETS_TOOLTIP'] = '顯示所有你可以使用的套裝，不僅限於本職業套裝。',
    ['SHOW_EXTRA_SETS_SUPPRESSED'] = '已被PvE/PvP篩選抑制',
    ['ONLY_FAVORITES'] = '僅顯示收藏',
    ['INCLUDE_FAVORITE_VARIANTS'] = '包含收藏的變體',
    ['HIDE_ITEMS_NOT_IN_SET'] = '隱藏套裝外物品',
    ['HIDE_ITEMS_NOT_IN_SET_TOOLTIP'] = '套用套裝時，隱藏其他部位。',
    ['MAX_MISSING_PIECES'] = '最大缺失件數',
    ['IGNORED_SLOTS'] = '忽略部位',
    ['IGNORED_SLOTS_TOOLTIP'] = '這些部位不計入最大缺失件數。',
    ['SKIPPED_SLOTS'] = '跳過部位',
    ['SKIPPED_SLOTS_TOOLTIP'] = '這些部位永遠不會被改變。',
    ['HIDDEN_SLOTS'] = '隱藏部位',
    ['HIDDEN_SLOTS_TOOLTIP'] = '這些部位始終隱藏（並且也不計入缺失）。',
    ['USE_CHARACTER_PROFILE'] = '使用角色設定檔',
    ['USE_CHARACTER_PROFILE_TOOLTIP'] = '為每個角色單獨儲存忽略、跳過和隱藏的部位設定。',
    ['RELOAD_SETS'] = '重新載入套裝',
    ['RESET'] = '重置',

    -- sets tab tooltip
    ['MISSING_SLOTS'] = '（缺少：%s）',
    ['SET_ID'] = '（套裝 ID：%d）',
    ['AND_MORE'] = ' 等另外 %d 件',
})

local config, private = MogPartialSets.module(), {}
MogPartialSets.config = config

---@type ConfigModule.Config
local configVar
---@type ConfigModule.Profile
local profileVar
---@type ConfigModule.Slots
local effectiveSlots

---@type ConfigModule.Slots
local DEFAULT_SLOTS = {
    ignored = {
        [INVSLOT_BACK] = true,
        [INVSLOT_WRIST] = true,
    },
    skipped = {},
    hidden = {},
}
---@type ConfigModule.Config
local DEFAULT_CONFIG = {
    showExtraSets = true,
    onlyFavorite = false,
    favoriteVariants = false,
    hideItemsNotInSet = true,
    maxMissingPieces = 2,
    slots = DEFAULT_SLOTS,
}
---@type ConfigModule.Profile
local DEFAULT_PROFILE = {
    enabled = false,
    slots = nil,
}

---@class (exact) ConfigModule.Config
---@field showExtraSets boolean
---@field maxMissingPieces integer
---@field onlyFavorite boolean
---@field favoriteVariants boolean
---@field hideItemsNotInSet boolean
---@field slots ConfigModule.Slots

---@class (exact) ConfigModule.Profile
---@field enabled boolean
---@field slots ConfigModule.Slots?

---@class (exact) ConfigModule.Slots
---@field ignored table<number, true>
---@field skipped table<number, true>
---@field hidden table<number, true>

function config.init()
    configVar = MogPartialSets.loadSavedVar(
        'MogPartialSetsAddonConfig',
        11,
        DEFAULT_CONFIG,
        private.configMigrations
    )

    profileVar = MogPartialSets.loadSavedVar(
        'MogPartialSetsAddonProfile',
        1,
        DEFAULT_PROFILE,
        private.profileMigrations
    )

    private.setEffectiveSlots()

    config.ignoredSlots = private.createSlotMap('ignored')
    config.skippedSlots = private.createSlotMap('skipped')
    config.hiddenSlots = private.createSlotMap('hidden')
end

---@return boolean
function config.showExtraSets()
    return configVar.showExtraSets
end

---@param value boolean
function config.setShowExtraSets(value)
    configVar.showExtraSets = value
    private.dispatchUpdate()
end

---@return boolean
function config.onlyFavorite()
    return configVar.onlyFavorite
end

---@param value boolean
function config.setOnlyFavorite(value)
    configVar.onlyFavorite = value
    private.dispatchUpdate()
end

---@return boolean
function config.favoriteVariants()
    return configVar.favoriteVariants
end

---@param value boolean
function config.setFavoriteVariants(value)
    configVar.favoriteVariants = value
    private.dispatchUpdate()
end

---@return boolean
function config.hideItemsNotInSet()
    return configVar.hideItemsNotInSet
end

---@param value boolean
function config.setHideItemsNotInSet(value)
    configVar.hideItemsNotInSet = value
    private.dispatchUpdate()
end

---@return integer
function config.maxMissingPieces()
    return configVar.maxMissingPieces
end

---@param value integer
function config.setMaxMissingPieces(value)
    configVar.maxMissingPieces = value
    private.dispatchUpdate()
end

---@return boolean
function config.characterProfileEnabled()
    return profileVar.enabled
end

---@param value boolean
function config.setCharacterProfileEnabled(value)
    profileVar.enabled = value
    private.setEffectiveSlots()
    private.dispatchUpdate()
end

function private.setEffectiveSlots()
    if profileVar.enabled then
        if not profileVar.slots then
            profileVar.slots = CopyTable(configVar.slots)
        end

        effectiveSlots = profileVar.slots
    else
        effectiveSlots = configVar.slots
    end
end

---@param key string
---@return ConfigModule.SlotMap
function private.createSlotMap(key)
    ---@class ConfigModule.SlotMap
    local map = {}

    ---@param invSlotId number
    ---@return boolean
    function map.has(invSlotId)
        return effectiveSlots[key][invSlotId] ~= nil
    end

    ---@return table<number, true> (copy)
    function map.getMap()
        return CopyTable(effectiveSlots[key])
    end

    ---@param invSlotId number
    ---@param enabled boolean
    function map.set(invSlotId, enabled)
        effectiveSlots[key][invSlotId] = enabled and true or nil
        private.dispatchUpdate()
    end

    function map.clear()
        effectiveSlots[key] = {}
        private.dispatchUpdate()
    end

    return map
end

---@type table<integer, fun(data: table)>
private.configMigrations = {
    -- pre-v7 not supported anymore
    [7] = function (data)
        data.splash = true
    end,

    [8] = function (data)
        data.splash = nil
    end,

    [9] = function (data)
        data.showExtraSets = data.enabled
        data.enabled = nil
        data.useHiddenIfMissing = true
        data.hiddenSlotMap = {}

        -- convert ignored slots from Enum.InventoryType to inventory slots
        local newIgnoredSlotMap = {}

        for invTypeEnumValue in pairs(data.ignoredSlotMap) do
            newIgnoredSlotMap[C_Transmog.GetSlotForInventoryType(invTypeEnumValue + 1)] = true
        end

        data.ignoredSlotMap = newIgnoredSlotMap
    end,

    [10] = function (data)
        data.hideItemsNotInSet = data.useHiddenIfMissing
    end,

    [11] = function (data)
        data.slots = {
            ignored = data.ignoredSlotMap,
            skipped = {},
            hidden = data.hiddenSlotMap,
        } --[[@as ConfigModule.Slots]]

        data.useHiddenIfMissing = nil
        data.ignoredSlotMap = nil
        data.hiddenSlotMap = nil
    end
}

---@type table<integer, fun(data: table)>
private.profileMigrations = {
    -- none yet
}

private.dispatchUpdate = MogPartialSets.debounce(0.1, function ()
    MogPartialSets.event.dispatch('config.update')
end)


---@class Addon
local setLoader, private = MogPartialSets.module(), {}
MogPartialSets.setLoader = setLoader

---@type SetLoaderModule.SetMap
local loadedSets = {}

---@type QueueWorker
local loadSetWorker
local loadSetWorkerFrameTimeLimit = 0.005
local loadSetWorkerUpdateFreq = 0.25
---@type number?
local loadSetWorkerBusySince

---@type table<number, SetLoaderModule.PendingSourceInfo>
local pendingSourceMap = {}
local pendingSourceTimeoutSecs = 10.0

local updateDebounce = 0.5
---@type number?
local lastUpdate

---@class SetLoaderModule.Set
---@field info TransmogSetInfo
---@field slots SetLoaderModule.SlotMap

---@class SetLoaderModule.Slot
---@field invSlotId number
---@field sources SetLoaderModule.SourceMap
---@field primarySourceId number
---@field usableSourceId number?

---@class SetLoaderModule.Source
---@field sourceId number
---@field usable boolean
---@field pending boolean

---@class SetLoaderModule.PendingSourceInfo
---@field sourceId number
---@field invSlotId number
---@field firstFetchedAt number
---@field sets table<number, true>

---@alias SetLoaderModule.SetMap table<number, SetLoaderModule.Set>
---@alias SetLoaderModule.SlotMap table<number, SetLoaderModule.Slot>
---@alias SetLoaderModule.SourceMap table<number, SetLoaderModule.Source>

function setLoader.init()
    loadSetWorker = MogPartialSets.createQueueWorker(
        private.loadSet,
        private.onLoadSetWorkerUpdate,
        loadSetWorkerFrameTimeLimit,
        loadSetWorkerUpdateFreq
    )
end

function setLoader.hook()
    MogPartialSets.on('TRANSMOG_COLLECTION_ITEM_UPDATE', private.onItemUpdate)
    MogPartialSets.on('TRANSMOG_COLLECTION_SOURCE_ADDED', private.onSourceAddedOrRemoved)
    MogPartialSets.on('TRANSMOG_COLLECTION_SOURCE_REMOVED', private.onSourceAddedOrRemoved)
    MogPartialSets.on('TRANSMOG_SETS_UPDATE_FAVORITE', private.onFavoritesUpdated)
end

--- @param setId number
--- @return SetLoaderModule.Set?
function setLoader.getSet(setId)
    if not loadedSets[setId] then
        local setInfo = C_TransmogSets.GetSetInfo(setId)

        if not setInfo then
            return nil
        end

        return private.loadSet(setInfo)
    end

    return loadedSets[setId]
end

---@param classMask integer
---@return SetLoaderModule.SetMap
function setLoader.getAllSets(classMask)
    local sets = {}
    local faction = UnitFactionGroup('player')

    for _, setInfo in ipairs(C_TransmogSets.GetAllSets()) do
        if
            -- match class
            (setInfo.classMask == 0 or bit.band(setInfo.classMask, classMask) ~= 0)
            -- match faction
            and (not setInfo.requiredFaction or setInfo.requiredFaction == faction)
        then
            local setId = setInfo.setID
            sets[setId] = loadedSets[setId] or loadSetWorker.add(setId, setInfo)
        end
    end

    return sets
end

---@return SetLoaderModule.SetMap
function setLoader.getAvailableSets()
    local sets = {}

    for _, setInfo in ipairs(C_TransmogSets.GetAvailableSets()) do
        local setId = setInfo.setID
        sets[setId] = loadedSets[setId] or loadSetWorker.add(setId, setInfo)
    end

    return sets
end

function setLoader.clearSetData()
    loadedSets = {}
    pendingSourceMap = {}
end

function setLoader.isLoading()
    return loadSetWorker.busy()
end

function setLoader.updatePerformance()
    local frameTimeLimit
    local fps = GetFramerate()

    if fps >= 90 then
        frameTimeLimit = 0.008
    elseif fps >= 60 then
        frameTimeLimit = 0.005
    else
        frameTimeLimit = 0.003
    end

    loadSetWorker.setFrameTimeLimit(frameTimeLimit)
end

---@param setInfo TransmogSetInfo
---@return SetLoaderModule.Set
function private.loadSet(setInfo)
    local set = private.createSet(setInfo)
    loadedSets[setInfo.setID] = set

    return set
end

---@param setInfo TransmogSetInfo
---@return SetLoaderModule.Set
function private.createSet(setInfo)
    local set = {
        info = setInfo,
        slots = private.loadSlots(setInfo.setID),
    }

    for invSlotId, slot in pairs(set.slots) do
        if not slot.usableSourceId then
            -- unusable slot, keep track of any pending sources
            for sourceId, source in pairs(slot.sources) do
                if source.pending then
                    if pendingSourceMap[sourceId] then
                        pendingSourceMap[sourceId].sets[setInfo.setID] = true
                    else
                        pendingSourceMap[sourceId] = {
                            sourceId = sourceId,
                            invSlotId = invSlotId,
                            firstFetchedAt = GetTime(),
                            sets = {[setInfo.setID] = true},
                        }
                    end
                end
            end
        end
    end

    return set
end

---@param setId number
---@return SetLoaderModule.SlotMap
function private.loadSlots(setId)
    ---@type SetLoaderModule.SlotMap
    local slots = {}

    for _, appearance in ipairs(C_TransmogSets.GetSetPrimaryAppearances(setId) or {}) do
        local sourceInfo = C_TransmogCollection.GetSourceInfo(appearance.appearanceID)

        if sourceInfo then
            local invSlotId = C_Transmog.GetSlotForInventoryType(sourceInfo.invType)

            if invSlotId then
                if not slots[invSlotId] then
                    local sources = private.loadSources(setId, invSlotId, sourceInfo)
                    local usableSourceId

                    for sourceId, source in pairs(sources) do
                        if source.usable then
                            usableSourceId = sourceId
                            break
                        end
                    end

                    slots[invSlotId] = {
                        invSlotId = invSlotId,
                        sources = sources,
                        primarySourceId = appearance.appearanceID,
                        usableSourceId = usableSourceId,
                    }
                else
                    -- sets with multiple primary apperances per slot exist, but are rare
                    -- append any new sources from that appearance in this case
                    local slot = slots[invSlotId]

                    for sourceId, source in pairs(private.loadSources(setId, invSlotId, sourceInfo)) do
                        if not slot.sources[sourceId] then
                            slot.sources[sourceId] = source

                            if source.usable and not slot.usableSourceId then
                                slot.usableSourceId = source.sourceId
                            end
                        end
                    end
                end
            end
        end
    end

    return slots
end

---@param setId number
---@param invSlotId number
---@param primarySourceInfo AppearanceSourceInfo
---@return SetLoaderModule.SourceMap
function private.loadSources(setId, invSlotId, primarySourceInfo)
    local sources = {}

    -- add primary source
    if primarySourceInfo.isCollected then
        sources[primarySourceInfo.sourceID] = private.createSource(primarySourceInfo)
    end

    -- add "official" sources
    for _, sourceInfo in ipairs(C_TransmogSets.GetSourcesForSlot(setId, invSlotId) or {}) do
        if sourceInfo.isCollected and not sources[sourceInfo.sourceID] then
            sources[sourceInfo.sourceID] = private.createSource(sourceInfo)
        end
    end

    -- add "lookalikes"
    local lookalikes = C_TransmogCollection.GetAppearanceSources(
        primarySourceInfo.visualID,
        C_TransmogCollection.GetCategoryForItem(primarySourceInfo.sourceID),
        TransmogUtil.GetTransmogLocation(
            invSlotId,
            Enum.TransmogType.Appearance,
            Enum.TransmogModification.Main
        )
    )

    for _, sourceInfo in ipairs(lookalikes or {}) do
        if sourceInfo.isCollected and not sources[sourceInfo.sourceID] then
            sources[sourceInfo.sourceID] = private.createSource(sourceInfo)
        end
    end

    return sources
end

---@param sourceInfo AppearanceSourceInfo
---@return SetLoaderModule.Source
function private.createSource(sourceInfo)
    local pending = (sourceInfo.name == nil)

    return {
        sourceId = sourceInfo.sourceID,
        usable = not pending and private.isUsableSource(sourceInfo),
        pending = pending,
    }
end

---@param source AppearanceSourceInfo
---@return boolean
function private.isUsableSource(source)
    return (
        (source.useErrorType == Enum.TransmogUseErrorType.None or source.useErrorType == nil)
        and source.isValidSourceForPlayer ~= false
        and source.canDisplayOnPlayer ~= false
        and source.meetsTransmogPlayerCondition ~= false
    )
end

function private.onItemUpdate()
    if next(pendingSourceMap) == nil then
        return -- nothing to do
    end

    local newPendingSourceMap = {}
    local skippedSourceMap = {}
    local setsChanged = false
    local now = GetTime()

    for sourceId, info in pairs(pendingSourceMap) do
        if not skippedSourceMap[sourceId] then
            local result, setUsableSlotsChanged = private.checkPendingSource(info, now)

            if result == 'pending' then
                -- still pending - check again later
                newPendingSourceMap[sourceId] = info
            elseif result == 'timeout' then
                -- timeout - don't try to load again
                -- (some sources are broken and just never load, but trying to triggers TRANSMOG_COLLECTION_ITEM_UPDATE again = loop)
            elseif result == 'loaded' then
                -- loaded
                if setUsableSlotsChanged then
                    setsChanged = true

                    -- given that this source is now loaded and usable, we don't need to check
                    -- any other pending sources for that slot in all affected sets
                    for setId in pairs(info.sets) do
                        local set = loadedSets[setId]
                        assert(set)
                        local slot = set.slots[info.invSlotId]
                        assert(slot)

                        for slotSourceId, source in pairs(slot.sources) do
                            if slotSourceId ~= sourceId and source.pending and pendingSourceMap[slotSourceId] then
                                -- other pending source is in pendingSourceMap, unmap this set from it
                                local hasOtherSets = private.unmapSetFromPendingSource(slotSourceId, setId)

                                if not hasOtherSets then
                                    -- no sets are waiting for this source now, we can safely skip it fully
                                    skippedSourceMap[slotSourceId] = true
                                end
                            end
                        end
                    end
                end
            else
                error('Unexpected result: ' .. result)
            end
        end
    end

    pendingSourceMap = newPendingSourceMap

    if setsChanged then
        private.dispatchUpdateDebounced()
    end
end

---@param info SetLoaderModule.PendingSourceInfo
---@param now number
---@return 'pending'|'timeout'|'loaded' result
---@return boolean? setUsableSlotsChanged
function private.checkPendingSource(info, now)
    if now - info.firstFetchedAt >= pendingSourceTimeoutSecs then
        return 'timeout'
    end

    local sourceInfo = C_TransmogCollection.GetSourceInfo(info.sourceId)

    if not sourceInfo or sourceInfo.name == nil then
        return 'pending'
    end

    local usable = private.isUsableSource(sourceInfo)
    local setUsableSlotsChanged = false

    for setId in pairs(info.sets) do
        local set = loadedSets[setId]
        assert(set)
        local slot = set.slots[info.invSlotId]
        assert(slot)
        local source = slot.sources[info.sourceId]
        assert(source)

        source.usable = usable
        source.pending = false

        if usable and not slot.usableSourceId then
            slot.usableSourceId = info.sourceId
            setUsableSlotsChanged = true
        end
    end

    return 'loaded', setUsableSlotsChanged
end

---@param sourceId number
---@param setId number
---@return boolean hasOtherSets
function private.unmapSetFromPendingSource(sourceId, setId)
    pendingSourceMap[sourceId].sets[setId] = nil

    return next(pendingSourceMap[sourceId].sets) ~= nil
end

---@param sourceId  number
function private.onSourceAddedOrRemoved(sourceId)
    -- clear pending state
    pendingSourceMap[sourceId] = nil

    -- re-load affected sets
    for _, setId in ipairs(C_TransmogSets.GetSetsContainingSourceID(sourceId) or {}) do
        if loadedSets[setId] then
            local setInfo = C_TransmogSets.GetSetInfo(setId)

            if setInfo then
                loadedSets[setId] = loadSetWorker.add(setId, setInfo)
            end
        end
    end
end

function private.onFavoritesUpdated()
    -- no payload, so have to iterate all sets
    for setId, set in pairs(loadedSets) do
        local setInfo = C_TransmogSets.GetSetInfo(setId)

        if setInfo then
            set.info.favorite = setInfo.favorite
        end
    end
end

function private.onLoadSetWorkerUpdate()
    if not loadSetWorker.busy() then
        -- loading just completed, update immediately
        loadSetWorkerBusySince = nil
        private.dispatchUpdate()

        return
    end

    -- still some sets left to load
    local now = GetTime()

    if not loadSetWorkerBusySince then
        loadSetWorkerBusySince = math.max(0, now - loadSetWorkerUpdateFreq)
    end

    -- if the loading process takes more than a second to finish, start updating at least once a second
    if
        now - loadSetWorkerBusySince > 1
        and (
            not lastUpdate
            or now - lastUpdate >= 1
        )
    then
        private.dispatchUpdate()
    end
end

function private.dispatchUpdate()
    lastUpdate = GetTime()
    MogPartialSets.event.dispatch('setLoader.update')
end

private.dispatchUpdateDebounced = MogPartialSets.debounce(updateDebounce, function ()
    if not loadSetWorker.busy() then
        private.dispatchUpdate()
    end
end)


---@class Addon
local ui = MogPartialSets.module()
MogPartialSets.ui = ui

local hooked = false

function ui.hook()
    if hooked then
        return
    end

    local setsFrame = TransmogFrame.WardrobeCollection.TabContent.SetsFrame

    MogPartialSets.ui.filter.hook(setsFrame)
    MogPartialSets.ui.setsTab.hook(setsFrame)

    hooked = true
end


---@class Addon
local filter, private = MogPartialSets.module(), {}
MogPartialSets.ui.filter = filter

local config = MogPartialSets.config
local setSlotOptions = {
    INVSLOT_HEAD, INVSLOT_SHOULDER, INVSLOT_BACK, INVSLOT_CHEST,
    INVSLOT_TABARD, INVSLOT_WRIST, INVSLOT_HAND, INVSLOT_WAIST,
    INVSLOT_LEGS, INVSLOT_FEET,
}
local armorSlotOptions = {
    INVSLOT_HEAD, INVSLOT_SHOULDER, INVSLOT_BACK, INVSLOT_CHEST,
    INVSLOT_TABARD, INVSLOT_BODY, INVSLOT_WRIST, INVSLOT_HAND,
    INVSLOT_WAIST, INVSLOT_LEGS, INVSLOT_FEET,
}
local hooked = false

function filter.hook(setsFrame)
    if hooked then
        return
    end

    local filterButton = setsFrame.FilterButton

    if not filterButton.menuGenerator then
        hooksecurefunc(setsFrame, 'InitFilterButton', function () private.setupButton(filterButton) end)
    else
        private.setupButton(filterButton)
    end

    hooked = true
end

function private.setupButton(filterButton)
    local originalGenerator = filterButton.menuGenerator

    local function getOptionTooltipFn(title, text)
        return function (tooltip)
            tooltip:SetOwner(tooltip:GetOwner(), 'ANCHOR_LEFT')
            GameTooltip_SetTitle(tooltip, title)
            GameTooltip_AddNormalLine(tooltip, text)
        end
    end

    filterButton:SetupMenu(function(dropdown, rootDescription)
        originalGenerator(dropdown, rootDescription)

        -- title
        rootDescription:CreateDivider()
        rootDescription:CreateTitle('MogPartialSets')

        -- show extra sets
        local showExtraSetsCheckbox = rootDescription:CreateCheckbox(
            'Show extra sets',
            function () return config.showExtraSets() end,
            function () config.setShowExtraSets(not config.showExtraSets()) end
        )

        local function updateShowExtraSetsCheckbox()
            showExtraSetsCheckbox:SetEnabled(not MogPartialSets.ui.setsTab.isPveOrPvpFiltered())
        end

        updateShowExtraSetsCheckbox()

        for _, desc in rootDescription:EnumerateElementDescriptions() do
            local data = desc:GetData()

            if data == LE_TRANSMOG_SET_FILTER_PVE or data == LE_TRANSMOG_SET_FILTER_PVP then
                -- update enabled state when PvE/PvP filter changes
                desc:HookResponder(updateShowExtraSetsCheckbox)
            end
        end

        local enabledExtraSetsTooltipFn = getOptionTooltipFn('Show extra sets', 'Show all the sets you can use, not just your class sets.')

        showExtraSetsCheckbox:SetTooltip(function(tooltip, elementDescription)
            enabledExtraSetsTooltipFn(tooltip)

            if MogPartialSets.ui.setsTab.isPveOrPvpFiltered() then
                GameTooltip_AddErrorLine(tooltip, 'Suppressed by PvE/PvP filter')
            end
        end)

        -- only favorites
        rootDescription:CreateCheckbox(
            'Only favorites',
            function () return config.onlyFavorite() end,
            function () config.setOnlyFavorite(not config.onlyFavorite()) end
        )

        -- include favorite variants
        local favoriteVariantsCheckbox = rootDescription:CreateCheckbox(
            'Include favorite variants',
            function () return config.favoriteVariants() end,
            function () config.setFavoriteVariants(not config.favoriteVariants()) end
        )

        favoriteVariantsCheckbox:SetEnabled(function () return config.onlyFavorite() end)

        -- hide items not in set
        local hideItemsNotInSetCheckbox = rootDescription:CreateCheckbox(
            'Hide items not in set',
            function () return config.hideItemsNotInSet() end,
            function () config.setHideItemsNotInSet(not config.hideItemsNotInSet()) end
        )

        hideItemsNotInSetCheckbox:SetTooltip(getOptionTooltipFn('Hide items not in set', 'When applying a set, hide the other slots.'))

        -- max missing pieces
        local maxMissingPiecesButton = rootDescription:CreateButton('Max missing pieces')

        for value = 0, 10 do
            maxMissingPiecesButton:CreateRadio(
                tostring(value),
                function () return config.maxMissingPieces() == value end,
                function ()
                    config.setMaxMissingPieces(value)

                    return MenuResponse.Refresh
                end
            )
        end

        -- ignored slots
        local ignoredSlotsButton = rootDescription:CreateButton('Ignored slots')

        ignoredSlotsButton:SetTooltip(getOptionTooltipFn('Ignored slots', 'These slots don\'t count towards max missing pieces.'))

        for _, invSlotId in ipairs(setSlotOptions) do
            ignoredSlotsButton:CreateCheckbox(
                MogPartialSets.const.slotLabelMap[invSlotId],
                function () return config.ignoredSlots.has(invSlotId) end,
                function () config.ignoredSlots.set(invSlotId, not config.ignoredSlots.has(invSlotId)) end
            )
        end

        ignoredSlotsButton:CreateButton('Reset', function ()
            config.ignoredSlots.clear()

            return MenuResponse.Refresh
        end)

        -- skipped slots
        local skippedSlotsButton = rootDescription:CreateButton('Skipped slots')

        skippedSlotsButton:SetTooltip(getOptionTooltipFn('Skipped slots', 'These slots are never changed.'))

        for _, invSlotId in ipairs(armorSlotOptions) do
            skippedSlotsButton:CreateCheckbox(
                MogPartialSets.const.slotLabelMap[invSlotId],
                function () return config.skippedSlots.has(invSlotId) end,
                function () config.skippedSlots.set(invSlotId, not config.skippedSlots.has(invSlotId)) end
            )
        end

        skippedSlotsButton:CreateButton('Reset', function ()
            config.skippedSlots.clear()

            return MenuResponse.Refresh
        end)

        -- hidden slots
        local hiddenSlotsButton = rootDescription:CreateButton('Hidden slots')

        hiddenSlotsButton:SetTooltip(getOptionTooltipFn('Hidden slots', 'These slots are always hidden (and also don\'t count as missing).'))

        for _, invSlotId in ipairs(setSlotOptions) do
            hiddenSlotsButton:CreateCheckbox(
                MogPartialSets.const.slotLabelMap[invSlotId],
                function () return config.hiddenSlots.has(invSlotId) end,
                function () config.hiddenSlots.set(invSlotId, not config.hiddenSlots.has(invSlotId)) end
            )
        end

        hiddenSlotsButton:CreateButton('Reset', function ()
            config.hiddenSlots.clear()

            return MenuResponse.Refresh
        end)

        rootDescription:CreateDivider()

        -- use character profile
        local useCharacterProfileCheckbox = rootDescription:CreateCheckbox(
            'Use character profile',
            function () return config.characterProfileEnabled() end,
            function () config.setCharacterProfileEnabled(not config.characterProfileEnabled()) end
        )

        useCharacterProfileCheckbox:SetTooltip(getOptionTooltipFn('Use character profile', 'Save the ignored, skipped and hidden slots per-character.'))

        -- reload button
        rootDescription:CreateButton('Reload sets', function ()
            MogPartialSets.setLoader.clearSetData()
            MogPartialSets.ui.setsTab.refreshIfVisible()
        end)
    end)
end


---@class Addon
local setsTab, private = MogPartialSets.module(), {}
MogPartialSets.ui.setsTab = setsTab

local setLoader = MogPartialSets.setLoader
local config = MogPartialSets.config
local setsFrameRef
---@type table<number, Enum.TransmogOutfitSlot[]>?
local outflitSlotCache
---@type table<number, number|false>
local hiddenSourceCache = {}
---@type UI.SetsTabModule.SetListInfo?
local currentSetListInfo
local lastTooltipModel
---@type table
local loadingIndicator

---@class (exact) UI.SetsTabModule.Filter
---@field searchQuery string
---@field maxMissingPieces integer
---@field onlyFavorite boolean
---@field favoriteVariants boolean
---@field ignoredSlots table<number, true>
---@field hiddenSlots table<number, true>
---@field classMask integer

---@class (exact) UI.SetsTabModule.SetListInfo
---@field count integer
---@field stats table<number, UI.SetsTabModule.SetStats>
---@field classMask integer
---@field hiddenSlotsKey string

---@class (exact) UI.SetsTabModule.SetStats
---@field collected integer
---@field total integer
---@field missingSlotsKey string
---@field favorite boolean

---@param setsFrame table
---@diagnostic disable: duplicate-set-field
function setsTab.hook(setsFrame)
    -- override set frame methods
    setsFrame.RefreshCollectionEntries = private.refreshCollectionEntries
    setsFrame.GetFirstMatchingSetID = private.getFirstMatchingSetId

    -- override model mixin methods
    local originalUpdateSet = TransmogSetModelMixin.UpdateSet
    local originalRefreshTooltip = TransmogSetModelMixin.RefreshTooltip
    local originalOnMouseDown = TransmogSetModelMixin.OnMouseDown

    function TransmogSetModelMixin:UpdateSet()
        originalUpdateSet(self)

        if self.elementData then
            self.Border:SetAtlas('transmog-setcard-default')
            self.Highlight:SetAtlas('transmog-setcard-default')

            if self.IncompleteOverlay then
                self.IncompleteOverlay:Hide()
            end
        end
    end

    function TransmogSetModelMixin:RefreshTooltip()
        originalRefreshTooltip(self)
        private.updateTooltip(self)
    end

    function TransmogSetModelMixin:OnMouseDown(button)
        if button == 'LeftButton' and self.elementData then
            PlaySound(SOUNDKIT.UI_TRANSMOG_ITEM_CLICK)
            private.applySet(self.elementData.set.setID)
        else
            originalOnMouseDown(self, button)
        end
    end

    -- store set frame reference
    setsFrameRef = setsFrame

    -- create loading indicator
    loadingIndicator = CreateFrame('Frame', nil, setsFrameRef.PagedContent, 'BackdropTemplate')
    loadingIndicator:SetFrameStrata('HIGH')
    loadingIndicator:SetPoint('CENTER')
    loadingIndicator:SetSize(220, 48)
    loadingIndicator:SetBackdrop({
        bgFile = 'Interface\\DialogFrame\\UI-DialogBox-Background',
        edgeFile = 'Interface\\DialogFrame\\UI-DialogBox-Border',
        edgeSize = 16,
        insets = {left = 6, right = 6, top = 6, bottom = 6},
    })
    loadingIndicator:SetBackdropColor(0, 0, 0, 0.6)
    loadingIndicator:Hide()

    local text = loadingIndicator:CreateFontString(nil, 'OVERLAY', 'GameFontNormalLarge')
    text:SetPoint('CENTER', 8, 0)
    text:SetText(SEARCH_LOADING_TEXT)

    local spinner = CreateFrame('Frame', nil, loadingIndicator, 'SpinnerTemplate')
    spinner:SetSize(20, 20)
    spinner:SetPoint('RIGHT', text, 'LEFT', -5, 0)

    -- refresh the UI when new data is loaded or config changes
    MogPartialSets.event.on('setLoader.update', setsTab.refreshIfVisible)
    MogPartialSets.event.on('config.update', setsTab.refreshIfVisible)
    MogPartialSets.on('TRANSMOG_COLLECTION_ITEM_UPDATE', private.refreshSetTooltip)
end

function setsTab.refreshIfVisible()
    if setsFrameRef and private.isVisible() then
        setsFrameRef:RefreshCollectionEntries()
    end
end

---@return boolean
function setsTab.isPveOrPvpFiltered()
    return (
        not C_TransmogSets.GetSetsFilter(LE_TRANSMOG_SET_FILTER_PVE)
        or not C_TransmogSets.GetSetsFilter(LE_TRANSMOG_SET_FILTER_PVP)
    )
end

function private.isVisible()
    if TransmogFrame and TransmogFrame:IsVisible() then
        local collection = TransmogFrame.WardrobeCollection

        return collection:GetTab() == collection.setsTabID
    end

    return false
end

---@param frame table
function private.refreshCollectionEntries(frame)
    if not private.isVisible() then
        -- don't refresh when not visible
        -- (loading when the transmog UI is initially open can cause missing items on the character preview because of bad UI code)
        return
    end

    ---@type UI.SetsTabModule.Filter
    local filter = {
        searchQuery = private.normalizeSearchString(frame.SearchBox:GetText()),
        maxMissingPieces = config.maxMissingPieces(),
        onlyFavorite = config.onlyFavorite(),
        favoriteVariants = config.favoriteVariants(),
        ignoredSlots = config.ignoredSlots.getMap(),
        hiddenSlots = config.hiddenSlots.getMap(),
        classMask = private.getCurrentClassMask(),
    }

    local sets

    setLoader.updatePerformance()

    if setsTab.isPveOrPvpFiltered() then
        -- if the built-in PvE or PvP filter is used, we have to show the pre-filtered available sets only
        -- (currently, the data whether a set is PvP or PvE is not present in TransmogSetInfo)
        sets = setLoader.getAvailableSets()
    else
        sets = setLoader.getAllSets(filter.classMask)
    end

    local setList, setListInfo = private.generateSetList(sets, filter)

    loadingIndicator:SetShown(setLoader.isLoading())

    if currentSetListInfo and private.setListInfosEqual(currentSetListInfo, setListInfo) then
        -- no change to set data
        return
    end

    frame.setsDataProvider:ClearSets()
    frame.setsDataProvider:SortSets(setList, false, false, false)

    local collectionElements = {}

    for _, setInfo in ipairs(setList) do
        local set = sets[setInfo.setID]
        local stats = setListInfo.stats[setInfo.setID]

        assert(set)
        assert(stats)

        local sourceData = private.buildSourceData(set, stats, filter)

        table.insert(collectionElements, {
            templateKey = 'COLLECTION_SET',
            set = setInfo,
            sourceData = sourceData,
            collectionFrame = frame,
        })
    end

    local dataProvider = CreateDataProvider({{elements = collectionElements}})
    frame.PagedContent:SetDataProvider(dataProvider, true)

    currentSetListInfo = setListInfo
end

---@param set SetLoaderModule.Set
---@param stats UI.SetsTabModule.SetStats
---@param filter UI.SetsTabModule.Filter
---@return table
function private.buildSourceData(set, stats, filter)
    local primaryAppearances = {}

    for _, slot in pairs(set.slots) do
        local sourceId

        if not slot.usableSourceId or filter.hiddenSlots[slot.invSlotId] then
            -- use a hidden appearance if slot is not collected or always hidden
            sourceId = private.getSourceIdForHiddenSlot(slot.invSlotId)
        else
            sourceId = slot.usableSourceId
        end

        if sourceId then
            table.insert(primaryAppearances, {
                appearanceID = sourceId,
                collected = true,
            })
        end
    end

    return {
        numCollected = stats.collected,
        numTotal = stats.total,
        primaryAppearances = primaryAppearances,
    }
end

---@param frame table
---@return number? appliedSetId
---@return boolean? hasPending
function private.getFirstMatchingSetId(frame)
    ---@type TransmogSetInfo[]
    local setsOnPage = {}

    frame.PagedContent:ForEachFrame(function(elementFrame, elementData)
        if elementData and elementData.set then
            table.insert(setsOnPage, elementData.set)
        end
    end)

    local transmogInfo = frame:GetCurrentTransmogInfoCallback()

    for _, usableSet in ipairs(setsOnPage) do
        local setMatched = false
        local hasPending = false

        for transmogLocation, info in pairs(transmogInfo) do
            if transmogLocation:IsAppearance() then
                local sourceIDs = C_TransmogOutfitInfo.GetSourceIDsForSlot(usableSet.setID, transmogLocation:GetSlot()) or {}
                local slotMatched = #sourceIDs == 0

                for _, sourceID in ipairs(sourceIDs) do
                    if info.transmogID == sourceID then
                        slotMatched = true

                        if info.hasPending then
                            hasPending = true
                        end

                        break
                    end
                end

                setMatched = slotMatched

                if not slotMatched then
                    break
                end
            end
        end

        if setMatched then
            return usableSet.setID, hasPending
        end
    end

    return nil, nil
end

---@return integer
function private.getCurrentClassMask()
    local classId = select(3, UnitClass('player'))

    if config.showExtraSets() then
        return MogPartialSets.const.armorTypeClassMasks[classId]
    end

    return MogPartialSets.const.classMasks[classId]
end

---@param sets SetLoaderModule.SetMap
---@param filter UI.SetsTabModule.Filter
---@return TransmogSetInfo[]
---@return UI.SetsTabModule.SetListInfo
function private.generateSetList(sets, filter)
    ---@type TransmogSetInfo[]
    local setList = {}
    ---@type UI.SetsTabModule.SetListInfo
    local setListInfo = {
        count = 0,
        stats = {},
        classMask = filter.classMask,
        hiddenSlotsKey = private.tableKeysToCacheKey(filter.hiddenSlots),
    }

    local isPrefiltered = setsTab.isPveOrPvpFiltered()

    for setId, set in pairs(sets) do
        local collectedSlots, totalSlots, missingSlots = private.getEffectiveSetProgress(set)
        local missingSlotCount = #missingSlots
        local isCollected = totalSlots > 0 and missingSlotCount == 0

        if
            collectedSlots > 0
            and missingSlotCount <= filter.maxMissingPieces
            and private.passesFavoriteFilter(set.info, sets, filter)
            and (isPrefiltered or private.passesCollectedFilter(isCollected))
            and (isPrefiltered or private.matchesSearchQuery(set.info, filter.searchQuery))
        then
            local setInfoCopy = CopyTable(set.info) -- setLoader caches these internally, so don't modify them
            setInfoCopy.collected = isCollected

            setListInfo.count = setListInfo.count + 1
            setListInfo.stats[setId] = {
                collected = collectedSlots,
                total = totalSlots,
                missingSlotsKey = private.tableValuesToCacheKey(missingSlots),
                favorite = set.info.favorite,
            }

            table.insert(setList, setInfoCopy)
        end
    end

    return setList, setListInfo
end

---@param infoA UI.SetsTabModule.SetListInfo
---@param infoB UI.SetsTabModule.SetListInfo
---@return boolean
function private.setListInfosEqual(infoA, infoB)
    if infoA.count ~= infoB.count or infoA.hiddenSlotsKey ~= infoB.hiddenSlotsKey then
        return false
    end

    for setId, statsA in pairs(infoA.stats) do
        local statsB = infoB.stats[setId]

        if
            not statsB
            or statsA.collected ~= statsB.collected
            or statsA.total ~= statsB.total
            or statsA.missingSlotsKey ~= statsB.missingSlotsKey
            or statsA.favorite ~= statsB.favorite
        then
            return false
        end
    end

    return true
end

---@param set SetLoaderModule.Set
---@return integer collected
---@return integer total
---@return number[] missing
function private.getSetProgress(set)
    local collected = 0
    local total = 0
    local missing = {}

    for invSlotId, slot in pairs(set.slots) do
        total = total + 1

        if slot.usableSourceId then
            collected = collected + 1
        else
            table.insert(missing, invSlotId)
        end
    end

    return collected, total, missing
end

---@param set SetLoaderModule.Set
---@return integer collected
---@return integer total
---@return number[] missing
function private.getEffectiveSetProgress(set)
    local collected = 0
    local total = 0
    local missing = {}

    for invSlotId, slot in pairs(set.slots) do
        if
            not config.ignoredSlots.has(invSlotId)
            and not config.hiddenSlots.has(invSlotId)
        then
            total = total + 1

            if slot.usableSourceId then
                collected = collected + 1
            else
                table.insert(missing, invSlotId)
            end
        end
    end

    return collected, total, missing
end

---@param str string
---@return string
function private.normalizeSearchString(str)
    return string.lower(strtrim(str))
end

---@param setInfo TransmogSetInfo
---@param query string
---@return boolean
function private.matchesSearchQuery(setInfo, query)
    if query == '' then
        return true
    end

    if private.matchSearchQuery(setInfo.name, query) then
        return true
    end

    if setInfo.label and private.matchSearchQuery(setInfo.label, query) then
        return true
    end

    return false
end

---@param haystack string
---@param query string
---@return boolean
function private.matchSearchQuery(haystack, query)
    return string.find(private.normalizeSearchString(haystack), query, 1, true) ~= nil
end

---@param isCollected boolean
---@return boolean
function private.passesCollectedFilter(isCollected)
    local collectedEnabled = C_TransmogSets.GetSetsFilter(LE_TRANSMOG_SET_FILTER_COLLECTED)
    local uncollectedEnabled = C_TransmogSets.GetSetsFilter(LE_TRANSMOG_SET_FILTER_UNCOLLECTED)

    if isCollected and not collectedEnabled then
        return false
    end

    if not isCollected and not uncollectedEnabled then
        return false
    end

    return true
end

---@param setInfo TransmogSetInfo
---@param sets SetLoaderModule.SetMap
---@param filter UI.SetsTabModule.Filter
---@return boolean
function private.passesFavoriteFilter(setInfo, sets, filter)
    if not filter.onlyFavorite then
        return true
    end

    if setInfo.favorite then
        return true
    end

    if filter.favoriteVariants and private.setHasFavoriteVariant(setInfo, sets) then
        return true
    end

    return false
end

---@param setInfo TransmogSetInfo
---@param sets SetLoaderModule.SetMap
---@return boolean
function private.setHasFavoriteVariant(setInfo, sets)
    local baseSetId

    if setInfo.baseSetID then
        -- this is a variant set
        ---@type number
        baseSetId = setInfo.baseSetID

        -- check whether the base set is favorited
        if sets[setInfo.baseSetID] and sets[setInfo.baseSetID].info.favorite then
            return true
        end
    else
        -- this is a base set
        baseSetId = setInfo.setID
    end

    -- check variants of the base set
    local variants = C_TransmogSets.GetVariantSets(baseSetId)

    if variants then
        for _, variant in ipairs(variants) do
            if variant.favorite then
                return true
            end
        end
    end

    return false
end

---@param setId number
function private.applySet(setId)
    local set = setLoader.getSet(setId)

    if not set then
        return
    end

    for invSlotId, slot in pairs(set.slots) do
        if not config.skippedSlots.has(invSlotId) then
            local sourceId

            if not slot.usableSourceId or config.hiddenSlots.has(invSlotId) then
                -- use a hidden appearance if slot is not collected or always hidden
                sourceId = private.getSourceIdForHiddenSlot(invSlotId)
            else
                sourceId = slot.usableSourceId
            end

            if sourceId then
                for _, outfitSlot in ipairs(private.getOutfitSlotsForInventorySlot(invSlotId)) do
                    private.applyPendingTransmog(outfitSlot, sourceId)
                end
            end
        end
    end

    if config.hideItemsNotInSet() then
        for invSlotId in pairs(MogPartialSets.const.hiddenItemMap) do
            if not config.skippedSlots.has(invSlotId) and not set.slots[invSlotId] then
                local sourceId = private.getSourceIdForHiddenSlot(invSlotId)

                if sourceId then
                    for _, outfitSlot in ipairs(private.getOutfitSlotsForInventorySlot(invSlotId)) do
                        private.applyPendingTransmog(outfitSlot, sourceId)
                    end
                end
            end
        end
    end
end

---@param outfitSlot Enum.TransmogOutfitSlot
---@param sourceId number
function private.applyPendingTransmog(outfitSlot, sourceId)
    local option = C_TransmogOutfitInfo.GetEquippedSlotOptionFromTransmogSlot(outfitSlot) or Enum.TransmogOutfitSlotOption.None
    local displayType

    if C_TransmogCollection.IsAppearanceHiddenVisual(sourceId) then
        displayType = Enum.TransmogOutfitDisplayType.Hidden
    else
        displayType = Enum.TransmogOutfitDisplayType.Assigned
    end

    C_TransmogOutfitInfo.SetPendingTransmog(
        outfitSlot,
        Enum.TransmogType.Appearance,
        option,
        sourceId,
        displayType
    )
end

---@param invSlotId number
---@return Enum.TransmogOutfitSlot[]
function private.getOutfitSlotsForInventorySlot(invSlotId)
    if not outflitSlotCache then
        outflitSlotCache = {}

        for _, slotInfo in ipairs(C_TransmogOutfitInfo.GetAllSlotLocationInfo() or {}) do
            local slotInvSlotId = GetInventorySlotInfo(slotInfo.slotName)

            if not outflitSlotCache[slotInvSlotId] then
                outflitSlotCache[slotInvSlotId] = {}
            end

            table.insert(outflitSlotCache[slotInvSlotId], slotInfo.slot)
        end
    end

    return outflitSlotCache[invSlotId] or {}
end

---@param invSlotId number
---@return number?
function private.getSourceIdForHiddenSlot(invSlotId)
    local sourceId = hiddenSourceCache[invSlotId]

    if sourceId then
        return sourceId
    end

    if sourceId == false then
        return nil
    end

    local hiddenItemId = MogPartialSets.const.hiddenItemMap[invSlotId]

    if hiddenItemId then
        sourceId = (select(2, C_TransmogCollection.GetItemInfo(MogPartialSets.const.hiddenItemMap[invSlotId])))
    end

    hiddenSourceCache[invSlotId] = sourceId or false

    return sourceId
end

function private.refreshSetTooltip()
    -- prevent a tooltip stuck on "Retrieving item information" (base UI bug)
    if setsFrameRef and setsFrameRef:IsVisible() and lastTooltipModel and lastTooltipModel:IsMouseOver() then
        lastTooltipModel:RefreshTooltip()
    end
end

function private.updateTooltip(model)
    if not model.elementData then
        return
    end

    lastTooltipModel = model

    local set = setLoader.getSet(model.elementData.set.setID)

    if not set then
        return
    end

    local collectedSlots, totalSlots, missingSlots = private.getSetProgress(set)

    local numLines = GameTooltip:NumLines()
    local lastLine = _G['GameTooltipTextLeft' .. numLines]
    local lastText = lastLine and lastLine:GetText()

    if lastText == TRANSMOG_SET_COMPLETE or lastText == TRANSMOG_SET_INCOMPLETE then
        local isComplete = (#missingSlots == 0)
        local statusText = isComplete and TRANSMOG_SET_COMPLETE or TRANSMOG_SET_INCOMPLETE
        local color = private.getSetProgressColor(collectedSlots, totalSlots)

        lastLine:SetText(string.format('|c%s%s %d/%d|r', color, statusText, collectedSlots, totalSlots))

        if not isComplete then
            local missingSlotsText = string.format('(Missing: %s)', private.formatSlotList(missingSlots, 3))
            GameTooltip:AddLine(missingSlotsText, 0.5, 0.5, 0.5)
        end
    end

    if IsAltKeyDown() then
        GameTooltip:AddLine(string.format('(Set ID: %d)', set.info.setID), 0.5, 0.5, 0.5)
    end

    GameTooltip:Show()
end

---@param slots number[]
---@param limit integer
---@return string
function private.formatSlotList(slots, limit)
    local slotMap = {}

    for _, invSlotId in ipairs(slots) do
        slotMap[invSlotId] = true
    end

    local slotNames = {}

    for invSlotId, name in pairs(MogPartialSets.const.slotLabelMap) do
        if slotMap[invSlotId] then
            table.insert(slotNames, name)

            if #slotNames >= limit then
                break
            end
        end
    end

    local suffix = ''

    if #slots > limit then
        suffix = string.format(' and %d more', #slots - limit)
    end

    return string.format('%s%s', table.concat(slotNames, ', '), suffix)
end

---@param collected integer
---@param total integer
---@return string
function private.getSetProgressColor(collected, total)
    if collected >= total then
        return 'ff008000'
    elseif total > 0 and collected / total >= 0.49 then
        return 'fffea000'
    else
        return 'ff800000'
    end
end

---@param values table<any, string|number>
---@return string
function private.tableValuesToCacheKey(values)
    local parts = {}

    for _, value in pairs(values) do
        table.insert(parts, value)
    end

    table.sort(parts)

    return table.concat(parts, ':')
end


---@param values table<string|number, any>
---@return string
function private.tableKeysToCacheKey(values)
    local parts = {}

    for key in pairs(values) do
        table.insert(parts, key)
    end

    table.sort(parts)

    return table.concat(parts, ':')
end

---@class Addon
local main, private = MogPartialSets.module(), {}

function main.init()
    MogPartialSets.on('TRANSMOGRIFY_OPEN', private.onTransmogrifyOpen)
end

function private.onTransmogrifyOpen()
    -- hook the transmog UI
    if C_AddOns.IsAddOnLoaded('Blizzard_Transmog') then
        private.hookComponents()
    else
        MogPartialSets.on('ADDON_LOADED', function (loadedAddonName)
            if loadedAddonName == 'Blizzard_Transmog' then
                private.hookComponents()

                return false
            end
        end)
    end

    return false
end

function private.hookComponents()
    MogPartialSets.setLoader.hook()
    MogPartialSets.ui.hook()
end
