local addonName, addon = ...

addon.LootTracker = addon.LootTracker or {}
addon.LootTracker.Crafting = addon.LootTracker.Crafting or {}

local Crafting = addon.LootTracker.Crafting
local Data = addon.LootTracker.Data
local PriceSource =
    addon.LootTracker.PriceSource

Crafting.MIN_SEASON_ITEM_LEVEL = 292

Crafting.PROFESSION_NAMES = {
    [1] = "ALCHEMY",
    [2] = "BLACKSMITHING",
    [3] = "LEATHERWORKING",
    [4] = "HERBALISM",
    [5] = "COOKING",
    [6] = "MINING",
    [7] = "TAILORING",
    [8] = "ENGINEERING",
    [9] = "ENCHANTING",
    [10] = "FISHING",
    [11] = "SKINNING",
    [12] = "JEWELCRAFTING",
    [13] = "INSCRIPTION",
}

local EQUIP_LOC_TO_SLOT = {
    INVTYPE_HEAD = 0,
    INVTYPE_NECK = 1,
    INVTYPE_SHOULDER = 2,
    INVTYPE_CLOAK = 3,
    INVTYPE_CHEST = 4,
    INVTYPE_ROBE = 4,
    INVTYPE_WRIST = 5,
    INVTYPE_HAND = 6,
    INVTYPE_WAIST = 7,
    INVTYPE_LEGS = 8,
    INVTYPE_FEET = 9,
    INVTYPE_WEAPONMAINHAND = 10,
    INVTYPE_2HWEAPON = 10,
    INVTYPE_WEAPON = 10,
    INVTYPE_RANGED = 10,
    INVTYPE_RANGEDRIGHT = 10,
    INVTYPE_HOLDABLE = 11,
    INVTYPE_SHIELD = 11,
    INVTYPE_WEAPONOFFHAND = 11,
    INVTYPE_FINGER = 12,
    INVTYPE_TRINKET = 13,
}

-- Craft browser EQUIPMENT TYPE classification. This is intentionally based on the item's
-- native class/subclass data instead of the selected character so players can
-- browse equipment intended for other armor classes.
Crafting.ITEM_TYPE_ORDER = {
    "ALL",
    "CLOTH",
    "LEATHER",
    "MAIL",
    "PLATE",
    "WEAPON",
    "JEWELRY",
    "OFFHAND",
    "OTHER",
}

Crafting.ITEM_TYPE_NAMES = {
    ALL = "ALL TYPES",
    CLOTH = "CLOTH",
    LEATHER = "LEATHER",
    MAIL = "MAIL",
    PLATE = "PLATE",
    WEAPON = "WEAPONS",
    JEWELRY = "JEWELRY",
    OFFHAND = "SHIELDS / OFF-HANDS",
    OTHER = "OTHER EQUIPMENT",
}

local ARMOR_SUBCLASS_TO_TYPE = {
    [1] = "CLOTH",
    [2] = "LEATHER",
    [3] = "MAIL",
    [4] = "PLATE",
}

local function SafeCall(func, ...)
    if not func then
        return nil
    end

    local ok, a, b, c, d, e =
        pcall(
            func,
            ...
        )

    if not ok then
        return nil
    end

    return a, b, c, d, e
end

function Crafting:GetProfessionName(professionID)
    return self.PROFESSION_NAMES[
        tonumber(professionID)
    ]
    or (
        "PROFESSION "
        .. tostring(
            professionID
            or "?"
        )
    )
end

function Crafting:GetItemSlotID(itemID)
    if not C_Item
        or not C_Item.GetItemInfoInstant
    then
        return 14
    end

    local _, _, _, equipLoc =
        C_Item.GetItemInfoInstant(
            itemID
        )

    return EQUIP_LOC_TO_SLOT[
        equipLoc
    ]
    or 14
end

function Crafting:IsEquipment(itemID)
    if not C_Item
        or not C_Item.GetItemInfoInstant
    then
        return false
    end

    local _, _, _, equipLoc =
        C_Item.GetItemInfoInstant(
            itemID
        )

    return equipLoc
        and equipLoc ~= ""
end

function Crafting:GetItemBrowseType(itemID)
    if not itemID or not C_Item or not C_Item.GetItemInfoInstant then
        return "OTHER"
    end

    local _, _, _, equipLoc, _, classID, subClassID =
        C_Item.GetItemInfoInstant(itemID)

    -- Rings, necks and trinkets are more useful as one browsing category than
    -- as Armor/Miscellaneous.
    if equipLoc == "INVTYPE_NECK"
        or equipLoc == "INVTYPE_FINGER"
        or equipLoc == "INVTYPE_TRINKET"
    then
        return "JEWELRY"
    end

    -- ItemClass 2 = Weapon, ItemClass 4 = Armor on mainline.  Numeric values
    -- are used as a compatibility fallback if Enum.ItemClass is unavailable.
    local weaponClass = Enum and Enum.ItemClass and Enum.ItemClass.Weapon or 2
    local armorClass = Enum and Enum.ItemClass and Enum.ItemClass.Armor or 4

    if classID == weaponClass then
        return "WEAPON"
    end

    if classID == armorClass then
        local armorType = ARMOR_SUBCLASS_TO_TYPE[tonumber(subClassID)]
        if armorType then
            return armorType
        end

        if equipLoc == "INVTYPE_SHIELD"
            or equipLoc == "INVTYPE_HOLDABLE"
            or equipLoc == "INVTYPE_WEAPONOFFHAND"
        then
            return "OFFHAND"
        end
    end

    if equipLoc == "INVTYPE_SHIELD"
        or equipLoc == "INVTYPE_HOLDABLE"
        or equipLoc == "INVTYPE_WEAPONOFFHAND"
    then
        return "OFFHAND"
    end

    return "OTHER"
end

function Crafting:GetOptionSlotID(option)
    if type(option) ~= "table" then
        return 14
    end

    if tonumber(option.slotID) then
        return tonumber(option.slotID)
    end

    local equipLoc = option.equipLoc
    if equipLoc and equipLoc ~= "" then
        return EQUIP_LOC_TO_SLOT[equipLoc] or 14
    end

    return self:GetItemSlotID(option.itemID)
end

function Crafting:GetOptionBrowseType(option)
    if type(option) ~= "table" then
        return "OTHER"
    end

    if option.browseType and option.browseType ~= "" then
        return option.browseType
    end

    local equipLoc = option.equipLoc
    local classID = tonumber(option.classID)
    local subClassID = tonumber(option.subclassID or option.subClassID)

    if equipLoc == "INVTYPE_NECK"
        or equipLoc == "INVTYPE_FINGER"
        or equipLoc == "INVTYPE_TRINKET"
    then
        return "JEWELRY"
    end

    local weaponClass = Enum and Enum.ItemClass and Enum.ItemClass.Weapon or 2
    local armorClass = Enum and Enum.ItemClass and Enum.ItemClass.Armor or 4

    if classID == weaponClass then
        return "WEAPON"
    end

    if classID == armorClass then
        local armorType = ARMOR_SUBCLASS_TO_TYPE[subClassID]
        if armorType then
            return armorType
        end
    end

    if equipLoc == "INVTYPE_SHIELD"
        or equipLoc == "INVTYPE_HOLDABLE"
        or equipLoc == "INVTYPE_WEAPONOFFHAND"
    then
        return "OFFHAND"
    end

    -- Shared records already carry class/equip metadata. Do not depend on the
    -- local client item cache merely to classify profession gear, bags, etc.
    if equipLoc and equipLoc ~= "" and classID ~= nil then
        return "OTHER"
    end

    return self:GetItemBrowseType(option.itemID)
end

function Crafting:GetBundledRecipeCount()
    local bundled = addon.LootTracker
        and addon.LootTracker.CraftDatabase
        and addon.LootTracker.CraftDatabase.recipes

    if type(bundled) ~= "table" then
        return 0
    end

    local count = 0
    for _ in pairs(bundled) do
        count = count + 1
    end
    return count
end

function Crafting:IsForSpec(itemID, specID)
    if not specID
        or not C_Item
        or not C_Item.GetItemSpecInfo
    then
        return true
    end

    local specs =
        SafeCall(
            C_Item.GetItemSpecInfo,
            itemID
        )

    if not specs
        or #specs == 0
    then
        return true
    end

    for _, allowedSpecID in ipairs(
        specs
    ) do
        if tonumber(allowedSpecID) ==
            tonumber(specID)
        then
            return true
        end
    end

    return false
end

------------------------------------------------------------
-- PROFESSION RECIPE DATABASE
------------------------------------------------------------

function Crafting:InitializeRecipeDatabase()
    WhatsNextCache = type(WhatsNextCache) == "table" and WhatsNextCache or {}

    WhatsNextCache.professionRecipes =
        WhatsNextCache.professionRecipes
        or {}

    WhatsNextCache.professionScan =
        WhatsNextCache.professionScan
        or {
            lastScan = 0,
            recipes = 0,
            skillLines = 0,
        }
end

function Crafting:GetRecipeCache()
    self:InitializeRecipeDatabase()
    return WhatsNextCache.professionRecipes
end

function Crafting:GetScanState()
    self:InitializeRecipeDatabase()
    return WhatsNextCache.professionScan
end

function Crafting:ClearRecipeCache()
    self:InitializeRecipeDatabase()

    WhatsNextCache.professionRecipes = {}
    WhatsNextCache.professionScan = {
        lastScan = 0,
        recipes = 0,
        skillLines = 0,
    }
end

function Crafting:GetCachedRecipeCount()
    local count = 0

    for _ in pairs(
        self:GetRecipeCache()
    ) do
        count = count + 1
    end

    return count
end

function Crafting:GetRecipeOutputItemID(
    recipeID,
    recipeInfo,
    schematic
)
    if schematic
        and schematic.outputItemID
    then
        return tonumber(
            schematic.outputItemID
        )
    end

    if recipeInfo
        and recipeInfo.qualityItemIDs
        and #recipeInfo.qualityItemIDs > 0
    then
        return tonumber(
            recipeInfo.qualityItemIDs[
                #recipeInfo.qualityItemIDs
            ]
        )
    end

    if C_TradeSkillUI
        and C_TradeSkillUI.GetRecipeOutputItemData
    then
        local outputData =
            SafeCall(
                C_TradeSkillUI.GetRecipeOutputItemData,
                recipeID,
                {}
            )

        if outputData
            and outputData.itemID
        then
            return tonumber(
                outputData.itemID
            )
        end
    end

    return nil
end

function Crafting:GetRecipeItemLevelRange(
    itemID,
    recipeInfo
)
    local baseLevel = nil

    if itemID
        and C_Item
        and C_Item.GetDetailedItemLevelInfo
    then
        local detailedLevel =
            SafeCall(
                C_Item.GetDetailedItemLevelInfo,
                itemID
            )

        baseLevel =
            tonumber(
                detailedLevel
            )
    end

    if not baseLevel
        and itemID
        and C_Item
        and C_Item.GetItemInfo
    then
        local _, _, _, level =
            C_Item.GetItemInfo(
                itemID
            )

        baseLevel =
            tonumber(level)
    end

    local minLevel =
        baseLevel

    local maxLevel =
        baseLevel

    if recipeInfo
        and recipeInfo.qualityIlvlBonuses
        and baseLevel
    then
        for _, bonus in ipairs(
            recipeInfo.qualityIlvlBonuses
        ) do
            local level =
                baseLevel
                + (
                    tonumber(bonus)
                    or 0
                )

            minLevel =
                math.min(
                    minLevel
                    or level,
                    level
                )

            maxLevel =
                math.max(
                    maxLevel
                    or level,
                    level
                )
        end
    end

    return minLevel, maxLevel
end

function Crafting:GetProfessionInfoForRecipe(
    recipeID,
    fallbackSkillLineID
)
    local info = nil

    if C_TradeSkillUI
        and C_TradeSkillUI.GetProfessionInfoByRecipeID
    then
        info =
            SafeCall(
                C_TradeSkillUI.GetProfessionInfoByRecipeID,
                recipeID
            )
    end

    local skillLineID =
        fallbackSkillLineID

    local skillLineName = nil
    local parentTradeSkillID = nil

    if C_TradeSkillUI
        and C_TradeSkillUI.GetTradeSkillLineForRecipe
    then
        local recipeSkillLineID,
            recipeSkillLineName,
            recipeParentID =
            SafeCall(
                C_TradeSkillUI.GetTradeSkillLineForRecipe,
                recipeID
            )

        skillLineID =
            recipeSkillLineID
            or skillLineID

        skillLineName =
            recipeSkillLineName

        parentTradeSkillID =
            recipeParentID
    end

    return {
        skillLineID =
            skillLineID,

        skillLineName =
            skillLineName,

        professionID =
            info
            and (
                info.parentProfessionID
                or info.professionID
            )
            or parentTradeSkillID,

        professionName =
            info
            and (
                info.parentProfessionName
                or info.professionName
            )
            or nil,

        expansionProfessionID =
            info
            and info.professionID
            or skillLineID,
    }
end

function Crafting:CacheRecipe(
    recipeID,
    skillLineID
)
    if not recipeID
        or not C_TradeSkillUI
    then
        return false
    end

    local recipeInfo =
        SafeCall(
            C_TradeSkillUI.GetRecipeInfo,
            recipeID
        )

    if not recipeInfo
        or recipeInfo.isDummyRecipe
        or recipeInfo.isGatheringRecipe
        or recipeInfo.isEnchantingRecipe
        or recipeInfo.isSalvageRecipe
    then
        return false
    end

    local schematic =
        SafeCall(
            C_TradeSkillUI.GetRecipeSchematic,
            recipeID,
            false
        )

    local itemID =
        self:GetRecipeOutputItemID(
            recipeID,
            recipeInfo,
            schematic
        )

    if not itemID
        or not self:IsEquipment(
            itemID
        )
    then
        return false
    end

    local profession =
        self:GetProfessionInfoForRecipe(
            recipeID,
            skillLineID
        )

    local minLevel,
        maxLevel =
        self:GetRecipeItemLevelRange(
            itemID,
            recipeInfo
        )

    local cache =
        self:GetRecipeCache()

    local existing =
        cache[
            tostring(
                recipeID
            )
        ]

    cache[
        tostring(
            recipeID
        )
    ] = {
        itemID =
            itemID,

        itemName =
            recipeInfo.name,

        spellID =
            recipeID,

        recipeID =
            recipeID,

        skillLineAbilityID =
            recipeInfo.skillLineAbilityID,

        professionID =
            profession.professionID,

        professionName =
            profession.professionName
            or self:GetProfessionName(
                profession.professionID
            ),

        expansionProfessionID =
            profession.expansionProfessionID,

        skillLineID =
            profession.skillLineID,

        skillLineName =
            profession.skillLineName,

        slotID =
            self:GetItemSlotID(
                itemID
            ),

        browseType =
            self:GetItemBrowseType(
                itemID
            ),

        iLvlMin =
            minLevel,

        iLvlMax =
            maxLevel,

        qualityIlvlBonuses =
            recipeInfo.qualityIlvlBonuses,

        qualityItemIDs =
            recipeInfo.qualityItemIDs,

        maxQuality =
            recipeInfo.maxQuality,

        learned =
            recipeInfo.learned == true,

        icon =
            recipeInfo.icon,

        scannedAt =
            time(),

        -- Keep the first discovery metadata if a later context
        -- provides less information.
        previousProfessionName =
            existing
            and existing.professionName
            or nil,
    }

    return true
end

function Crafting:ScanCurrentProfession(
    skillLineID
)
    if not C_TradeSkillUI
        or not C_TradeSkillUI.GetAllRecipeIDs
    then
        return 0
    end

    local recipeIDs =
        SafeCall(
            C_TradeSkillUI.GetAllRecipeIDs
        )

    if not recipeIDs
        or #recipeIDs == 0
    then
        return 0
    end

    local added = 0

    for _, recipeID in ipairs(
        recipeIDs
    ) do
        if self:CacheRecipe(
            recipeID,
            skillLineID
        ) then
            added = added + 1
        end
    end

    return added
end

function Crafting:GetCandidateCurrentSkillLines()
    local skillLines = {}

    if not C_TradeSkillUI
        or not C_TradeSkillUI.GetAllProfessionTradeSkillLines
    then
        return skillLines
    end

    local allLines =
        SafeCall(
            C_TradeSkillUI.GetAllProfessionTradeSkillLines
        )
        or {}

    --------------------------------------------------------
    -- The API can include expansion variants. Keep the
    -- numerically newest variant per base profession as the
    -- first candidate for the active expansion.
    --------------------------------------------------------

    local newestByParent = {}

    for _, skillLineID in ipairs(
        allLines
    ) do
        local info = nil

        if C_TradeSkillUI.GetProfessionInfoBySkillLineID then
            info =
                SafeCall(
                    C_TradeSkillUI.GetProfessionInfoBySkillLineID,
                    skillLineID
                )
        end

        local parent =
            info
            and (
                info.parentProfessionID
                or info.professionID
            )
            or skillLineID

        local current =
            newestByParent[
                tostring(parent)
            ]

        if not current
            or tonumber(skillLineID) >
                tonumber(current.skillLineID)
        then
            newestByParent[
                tostring(parent)
            ] = {
                skillLineID =
                    skillLineID,

                info =
                    info,
            }
        end
    end

    for _, entry in pairs(
        newestByParent
    ) do
        table.insert(
            skillLines,
            entry.skillLineID
        )
    end

    table.sort(skillLines)

    return skillLines
end

function Crafting:ScanProfessionDatabase()
    self:InitializeRecipeDatabase()

    if not C_TradeSkillUI then
        return 0, "NO_API"
    end

    local before =
        self:GetCachedRecipeCount()

    local scannedSkillLines = 0

    --------------------------------------------------------
    -- First capture whatever profession is already loaded.
    -- GetAllRecipeIDs includes learned and unlearned recipes.
    --------------------------------------------------------

    local currentChild = nil

    if C_TradeSkillUI.GetChildProfessionInfo then
        currentChild =
            SafeCall(
                C_TradeSkillUI.GetChildProfessionInfo
            )
    end

    local currentSkillLineID =
        currentChild
        and currentChild.professionID
        or nil

    local candidates = self:GetCandidateCurrentSkillLines()
    local candidateSet = {}
    for _, candidateID in ipairs(candidates) do
        candidateSet[tonumber(candidateID)] = true
    end

    -- Never import an old-expansion profession just because the player has
    -- that profession page open. Only the newest child skill lines are used.
    if currentSkillLineID and candidateSet[tonumber(currentSkillLineID)] then
        self:ScanCurrentProfession(currentSkillLineID)
    end

    --------------------------------------------------------
    -- Then try to switch among the newest profession skill
    -- lines the client exposes. This is deliberately wrapped
    -- in pcall because Blizzard may refuse unavailable lines.
    --------------------------------------------------------

    if C_TradeSkillUI.SetProfessionChildSkillLineID
        and C_TradeSkillUI.GetAllRecipeIDs
    then
        for _, skillLineID in ipairs(
            candidates
        ) do
            local ok =
                pcall(
                    C_TradeSkillUI.SetProfessionChildSkillLineID,
                    skillLineID
                )

            if ok then
                local count =
                    self:ScanCurrentProfession(
                        skillLineID
                    )

                if count > 0 then
                    scannedSkillLines =
                        scannedSkillLines + 1
                end
            end
        end

        if currentSkillLineID then
            pcall(
                C_TradeSkillUI.SetProfessionChildSkillLineID,
                currentSkillLineID
            )
        end
    end

    local after =
        self:GetCachedRecipeCount()

    local state =
        self:GetScanState()

    state.lastScan =
        time()

    state.recipes =
        after

    state.skillLines =
        scannedSkillLines

    return after - before, nil
end

function Crafting:MergeCraftingOrderOptions()
    --------------------------------------------------------
    -- Primary global source for the crafted browser.
    -- GetCustomerOptions is Blizzard's own customer-order catalogue and,
    -- unlike GetAllRecipeIDs, is not limited to the profession currently
    -- opened/known by this character. currentExpansionOnly keeps old
    -- expansion equipment out of the Loot Tracker.
    --------------------------------------------------------

    if not C_CraftingOrders
        or not C_CraftingOrders.GetCustomerOptions
    then
        return 0, "NO_CRAFTING_ORDERS_API"
    end

    local params = {
        categoryFilters = {},
        searchText = nil,
        minLevel = 0,
        maxLevel = 999,
        uncollectedOnly = false,
        usableOnly = false,
        upgradesOnly = false,
        currentExpansionOnly = true,

        includePoor = true,
        includeCommon = true,
        includeUncommon = true,
        includeRare = true,
        includeEpic = true,
        includeLegendary = true,
        includeArtifact = true,

        isFavoritesSearch = false,
    }

    local results =
        SafeCall(
            C_CraftingOrders.GetCustomerOptions,
            params
        )

    if not results
        or type(results.options) ~= "table"
    then
        return 0, "CATALOG_NOT_READY"
    end

    local cache = self:GetRecipeCache()
    local added = 0
    local currentExpansionID = nil

    for _, option in ipairs(results.options) do
        local itemID = tonumber(option.itemID)
        local recipeID = tonumber(option.spellID)

        if itemID
            and recipeID
            and self:IsEquipment(itemID)
        then
            local key = tostring(recipeID)
            local profession =
                self:GetProfessionInfoForRecipe(
                    recipeID,
                    option.skillUpSkillLineID
                )

            if not cache[key] then
                added = added + 1
            end

            currentExpansionID =
                tonumber(option.expansionID)
                or currentExpansionID

            cache[key] = {
                itemID = itemID,
                itemName = option.itemName,
                spellID = recipeID,
                recipeID = recipeID,
                skillLineAbilityID = option.skillLineAbilityID,
                professionID = option.professionID
                    or profession.professionID,
                professionName = profession.professionName
                    or self:GetProfessionName(option.professionID),
                expansionProfessionID = profession.expansionProfessionID,
                skillLineID = profession.skillLineID
                    or option.skillUpSkillLineID,
                skillLineName = profession.skillLineName,
                slotID = self:GetItemSlotID(itemID),
                browseType = self:GetItemBrowseType(itemID),
                iLvlMin = option.iLvlMin,
                iLvlMax = option.iLvlMax,
                qualityIlvlBonuses = option.qualityIlvlBonuses,
                expansionID = option.expansionID,
                source = "BLIZZARD_GLOBAL_CRAFTING_CATALOG",
                scannedAt = time(),
            }
        end
    end

    local state = self:GetScanState()
    state.globalCatalogReady = #results.options > 0
    state.globalCatalogCount = #results.options
    state.currentExpansionID = currentExpansionID
    state.globalCatalogScan = time()

    return added, nil
end

function Crafting:GetProfessionRecipeOptions(
    classID,
    specID,
    slotID,
    browseType
)
    self:InitializeRecipeDatabase()

    --------------------------------------------------------
    -- Lazy cache initialization only. The expensive Blizzard database scan
    -- happens once on the first crafted-browser use, then only after the WoW
    -- patch key changes. Filtering below is always cache-only.
    --------------------------------------------------------

    -- The bundled community database is the primary catalog. Never block its
    -- rendering on a live Blizzard profession scan. Live synchronization is
    -- only a fallback when no bundled database is present.
    local bundled = addon.LootTracker
        and addon.LootTracker.CraftDatabase
        and addon.LootTracker.CraftDatabase.recipes

    local hasBundled = type(bundled) == "table" and next(bundled) ~= nil

    if not hasBundled and self.EnsureBlizzardDatabase then
        pcall(function() self:EnsureBlizzardDatabase() end)
    end

    local result = {}
    local seenItems = {}

    local function ConsiderOption(option)
        local itemID =
            tonumber(
                option.itemID
            )

        local optionEquipLoc = type(option) == "table" and option.equipLoc or nil
        local isEquipment = false

        -- Bundled/community records already contain authoritative equipLoc.
        -- Do not require the local WoW item cache for those entries.
        if optionEquipLoc and optionEquipLoc ~= ""
            and optionEquipLoc ~= "INVTYPE_NON_EQUIP_IGNORE"
        then
            isEquipment = true
        elseif itemID then
            isEquipment = self:IsEquipment(itemID)
        end

        if itemID and isEquipment then
            local itemSlotID =
                self:GetOptionSlotID(option)

            -- Normalize the option once so every downstream renderer groups
            -- bundled and client records identically.
            option.slotID = itemSlotID

            local slotMatches =
                slotID == nil
                or tonumber(slotID) < 0
                or tonumber(itemSlotID) ==
                    tonumber(slotID)

            local itemBrowseType =
                self:GetOptionBrowseType(option)

            local typeMatches =
                browseType == nil
                or browseType == "ALL"
                or itemBrowseType == browseType

            if slotMatches and typeMatches then
                option.browseType = itemBrowseType

                local current =
                    seenItems[
                        tostring(itemID)
                    ]

                if not current
                    or (
                        tonumber(
                            option.iLvlMax
                        )
                        or 0
                    ) > (
                        tonumber(
                            current.iLvlMax
                        )
                        or 0
                    )
                then
                    seenItems[
                        tostring(itemID)
                    ] = option
                end
            end
        end
    end

    -- Source 1 (primary): shared community database bundled with the addon.
    if type(bundled) == "table" then
        for _, option in pairs(bundled) do
            ConsiderOption(option)
        end
    end

    -- Source 2 (supplemental): client cache, but only for skill lines that
    -- exist in the bundled current-expansion database. This prevents Classic,
    -- Dragonflight, Khaz Algar, etc. from leaking into the Midnight browser.
    local bundledSkillLines = {}
    if type(bundled) == "table" then
        for _, option in pairs(bundled) do
            local skillLineID = tonumber(option.skillLineID)
            if skillLineID then
                bundledSkillLines[skillLineID] = true
            end
        end
    end

    for _, option in pairs(self:GetRecipeCache()) do
        local skillLineID = tonumber(option and option.skillLineID)
        if not hasBundled or (skillLineID and bundledSkillLines[skillLineID]) then
            ConsiderOption(option)
        end
    end

    for _, option in pairs(
        seenItems
    ) do
        table.insert(
            result,
            option
        )
    end

    table.sort(
        result,
        function(a, b)
            local aSlot =
                tonumber(a.slotID)
                or 99

            local bSlot =
                tonumber(b.slotID)
                or 99

            if aSlot ~= bSlot then
                return aSlot < bSlot
            end

            if tostring(
                a.professionName
                or ""
            ) ~= tostring(
                b.professionName
                or ""
            ) then
                return tostring(
                    a.professionName
                    or ""
                ) < tostring(
                    b.professionName
                    or ""
                )
            end

            return tostring(
                a.itemName
                or ""
            ) < tostring(
                b.itemName
                or ""
            )
        end
    )

    if #result == 0 then
        local totalSources = self:GetCachedRecipeCount() + self:GetBundledRecipeCount()
        if totalSources == 0 then
            return result, "PROFESSION_DB_EMPTY"
        end
        return result, "NO_FILTER_MATCH"
    end

    return result, nil
end

------------------------------------------------------------
-- EVENT-DRIVEN CACHE
------------------------------------------------------------

function Crafting:RegisterProfessionEvents()
    -- Intentionally disabled. Profession events must not trigger database
    -- rescans; the catalogue is refreshed lazily only on first use/new patch.
end

-- Reagent selection / craft planner code was removed in 0.15.3.
-- Crafted items are loot-catalog entries only and are added directly to My Loot.
