local PT = AhUI
if not PT then return end

PT.RecipeList = PT.RecipeList or {}
local M = PT.RecipeList

local G = PT.ProfessionGuides
local Shopping = PT.ProfessionGuides_Shopping
local L = PT.L or {}

local FONT = AhUI.FONT or STANDARD_TEXT_FONT
local DEFAULT_SCALE = AhUI.SCALE_DEFAULT or 1.0
local SCALE_STEP = AhUI.SCALE_STEP or 0.05
local SCALE_MIN = 0.8
local SCALE_MAX = AhUI.SCALE_MAX or 1.5

local SIZE_W, SIZE_H = 1040, 520
local MIN_W, MIN_H = 860, 360
local MAX_W, MAX_H = 1320, 920
local ROW_H = 30
local ROW_GAP = 2
local QUEUE_W = 340
local ALL_RECIPES_ICON = 134400

local COL_TRACK_X   = 4
local COL_TRACK_W   = 24

local COL_REAGENT_X = 60
local COL_REAGENT_W = 360

local COL_GRADE_X   = 430
local COL_GRADE_W   = 90

local COL_NEED_X    = 520
local COL_NEED_W    = 54

local COL_HAVE_X    = 580
local COL_HAVE_W    = 54

local COL_STATE_X   = 640
local COL_STATE_W   = 30

local DETAILS_MIN_W = 680

local scrubsecretvalues = _G.scrubsecretvalues

local GetProfessionForm
local GetSelectedSkillLineID

local function T(key, default)
    return L[key] or default or key
end

local function ScrubSecret(value)
    if scrubsecretvalues then
        return scrubsecretvalues(value)
    end
    return value
end

local function GetDB()
    AhUI_DB = AhUI_DB or {}
    AhUI_DB.recipeList = AhUI_DB.recipeList or {}

    local db = AhUI_DB.recipeList
    db.queue = db.queue or {}
    db.trackers = db.trackers or {}
    db.ui = db.ui or {}
    db.trackerPositions = db.trackerPositions or {}
    db.scans = db.scans or {}

    if type(db.ui.activeTab) ~= "number" then
        db.ui.activeTab = 2
    end

    return db
end

local function GetCharacterKey()
    local name = UnitName("player")
    local realm = GetNormalizedRealmName()
    if not name then return nil end
    return name .. "-" .. (realm or "")
end

local function FormatCharacterLabel(charKey)
    if type(charKey) ~= "string" or charKey == "" then
        return "?"
    end

    local name, realm = strsplit("-", charKey)
    if not name or name == "" then
        return charKey
    end

    if not realm or realm == "" then
        return name
    end

    return name .. "-" .. realm
end

local function FormatDateTime(ts)
    ts = tonumber(ts)
    if not ts or ts <= 0 then
        return T("RL_SCAN_NOT_SCANNED", "Not scanned")
    end
    return date("%d.%m.%Y %H:%M", ts)
end

local function CollectKnownCharacters()
    local chars = {}
    local seen = {}
    local current = GetCharacterKey()

    local function Add(charKey)
        if type(charKey) ~= "string" or charKey == "" or seen[charKey] then
            return
        end

        local name, realm = strsplit("-", charKey)
        if name and realm and realm ~= "" then
            seen[charKey] = true
            table.insert(chars, name .. "-" .. realm)
        end
    end

    Add(current)

    local db = GetDB()
    for charKey in pairs(db.scans or {}) do
        Add(charKey)
    end

    table.sort(chars, function(a, b)
        if a == current then return true end
        if b == current then return false end
        return a < b
    end)

    return chars
end

local function GetProfessionMeta(skillLineID)
    local name = T("RL_UNKNOWN_PROFESSION", "Unknown profession")
    local icon = 136243

    skillLineID = tonumber(skillLineID)
    if not skillLineID or skillLineID <= 0 then
        return name, icon
    end

    if C_TradeSkillUI and C_TradeSkillUI.GetProfessionInfoBySkillLineID then
        local ok, info = pcall(C_TradeSkillUI.GetProfessionInfoBySkillLineID, skillLineID)
        if ok and type(info) == "table" then
            name = info.professionName or info.skillLineName or info.name or name
            icon = info.professionIcon or info.icon or icon
        end
    end

    return name, icon
end

local function IsGenericProfessionIcon(icon)
    return tonumber(icon) == 136243
end

local function GetProfessionIconByName(professionName)
    local name = string.lower(tostring(professionName or ""))
    name = string.gsub(name, "ё", "е")

    local map = {
        { spellID = 2259,  keys = { "alchemy", "алхим" } },
        { spellID = 2018,  keys = { "blacksmith", "кузнеч" } },
        { spellID = 7411,  keys = { "enchant", "наложен" } },
        { spellID = 4036,  keys = { "engineering", "инженер" } },
        { spellID = 2366,  keys = { "herbal", "травнич" } },
        { spellID = 45357, keys = { "inscription", "начерт" } },
        { spellID = 25229, keys = { "jewel", "ювелир" } },
        { spellID = 2108,  keys = { "leather", "кожев" } },
        { spellID = 2575,  keys = { "mining", "горное" } },
        { spellID = 8613,  keys = { "skinning", "снятие шкур" } },
        { spellID = 3908,  keys = { "tailoring", "портняж" } },
        { spellID = 2550,  keys = { "cooking", "кулинар" } },
        { spellID = 7620,  keys = { "fishing", "рыбная ловля", "рыбная", "рыбная лов" } },
    }

    for _, entry in ipairs(map) do
        for _, key in ipairs(entry.keys) do
            if string.find(name, key, 1, true) then
                if C_Spell and C_Spell.GetSpellTexture then
                    local icon = C_Spell.GetSpellTexture(entry.spellID)
                    if icon then
                        return icon
                    end
                end

                if GetSpellTexture then
                    local icon = GetSpellTexture(entry.spellID)
                    if icon then
                        return icon
                    end
                end
            end
        end
    end

    return nil
end

local function GetProfessionGroupMeta(skillLineID, professionID, professionGroupName, professionName, professionIcon)
    local resolvedProfessionID = tonumber(professionID)
    local resolvedGroupName = professionGroupName
    local resolvedIcon = professionIcon

    skillLineID = tonumber(skillLineID)

    if C_TradeSkillUI and C_TradeSkillUI.GetProfessionInfoBySkillLineID and skillLineID and skillLineID > 0 then
        local ok, info = pcall(C_TradeSkillUI.GetProfessionInfoBySkillLineID, skillLineID)
        if ok and type(info) == "table" then
            resolvedProfessionID = resolvedProfessionID
                or tonumber(ScrubSecret(info.profession))
                or tonumber(ScrubSecret(info.parentProfessionID))
            resolvedGroupName = resolvedGroupName
                or info.parentProfessionName
                or info.parentSkillLineName
                or info.parentName
            resolvedIcon = resolvedIcon
                or info.parentProfessionIcon
                or info.professionIcon
                or info.icon
        end
    end

    if (not resolvedGroupName or resolvedGroupName == "") and ProfessionsFrame and type(ProfessionsFrame.GetProfessionInfo) == "function" then
        local okFrame, frameInfo = pcall(ProfessionsFrame.GetProfessionInfo, ProfessionsFrame)
        if okFrame and type(frameInfo) == "table" then
            local frameSkillLineID = tonumber(ScrubSecret(frameInfo.professionID)) or tonumber(ScrubSecret(frameInfo.skillLineID))
            if not skillLineID or not frameSkillLineID or frameSkillLineID == skillLineID then
                resolvedProfessionID = resolvedProfessionID
                    or tonumber(ScrubSecret(frameInfo.profession))
                    or tonumber(ScrubSecret(frameInfo.parentProfessionID))
                resolvedGroupName = resolvedGroupName
                    or frameInfo.parentProfessionName
                    or frameInfo.parentSkillLineName
                    or frameInfo.parentName
                resolvedIcon = resolvedIcon
                    or frameInfo.parentProfessionIcon
                    or frameInfo.professionIcon
                    or frameInfo.icon
            end
        end
    end

    if (not resolvedGroupName or resolvedGroupName == "") and C_TradeSkillUI and C_TradeSkillUI.GetChildProfessionInfo then
        local okChild, childInfo = pcall(C_TradeSkillUI.GetChildProfessionInfo)
        if okChild and type(childInfo) == "table" then
            local childSkillLineID = tonumber(ScrubSecret(childInfo.professionID))
                or tonumber(ScrubSecret(childInfo.skillLineID))
            if not skillLineID or not childSkillLineID or childSkillLineID == skillLineID then
                resolvedProfessionID = resolvedProfessionID
                    or tonumber(ScrubSecret(childInfo.profession))
                    or tonumber(ScrubSecret(childInfo.parentProfessionID))
                resolvedGroupName = resolvedGroupName
                    or childInfo.parentProfessionName
                    or childInfo.parentSkillLineName
                    or childInfo.parentName
                resolvedIcon = resolvedIcon
                    or childInfo.parentProfessionIcon
                    or childInfo.professionIcon
                    or childInfo.icon
            end
        end
    end

    if not resolvedGroupName or resolvedGroupName == "" then
        resolvedGroupName = professionGroupName or professionName
    end

    if not resolvedIcon then
        resolvedIcon = professionIcon or select(2, GetProfessionMeta(skillLineID)) or 136243
    end

    return resolvedProfessionID, resolvedGroupName or professionName or T("RL_UNKNOWN_PROFESSION", "Unknown profession"), resolvedIcon or 136243
end

local function GetProfessionGroupKey(professionID, professionGroupName, professionName, skillLineID)
    local resolvedProfessionID, resolvedGroupName = GetProfessionGroupMeta(skillLineID, professionID, professionGroupName, professionName)
    resolvedProfessionID = tonumber(resolvedProfessionID)
    if resolvedProfessionID and resolvedProfessionID > 0 then
        return "prof:" .. tostring(resolvedProfessionID)
    end

    if type(resolvedGroupName) == "string" and resolvedGroupName ~= "" then
        return "name:" .. resolvedGroupName
    end

    return "skill:" .. tostring(tonumber(skillLineID) or 0)
end

local function ResolveProfessionGroupName(professionInfo, fallbackName)
    if type(professionInfo) == "table" then
        return professionInfo.parentProfessionName
            or professionInfo.parentSkillLineName
            or professionInfo.parentName
            or professionInfo.professionName
            or professionInfo.skillLineName
            or professionInfo.name
            or fallbackName
    end

    return fallbackName
end

local function GetRecipeCategorySnapshot(recipeInfo, context)
    local categoryID = tonumber(recipeInfo and recipeInfo.categoryID)
    local categoryName = recipeInfo and recipeInfo.categoryName or nil
    local epochSkillLineID = tonumber(context and context.skillLineID) or nil

    if categoryID and categoryID > 0 and C_TradeSkillUI and C_TradeSkillUI.GetCategoryInfo then
        local okCat, categoryInfo = pcall(C_TradeSkillUI.GetCategoryInfo, categoryID)
        if okCat and type(categoryInfo) == "table" then
            categoryName = categoryInfo.name or categoryName
            epochSkillLineID = tonumber(ScrubSecret(categoryInfo.skillLineID)) or epochSkillLineID
        end
    end

    local epochName = context and context.professionName or T("RL_FILTER_UNKNOWN", "Unknown")

    if not categoryName or categoryName == "" then
        categoryName = T("RL_FILTER_UNCATEGORIZED", "Uncategorized")
    end

    return {
        categoryID = categoryID or 0,
        categoryName = categoryName,
        epochSkillLineID = epochSkillLineID,
        epochName = epochName,
    }
end



local function NormalizeCharScanData(charKey)
    local db = GetDB()
    db.scans = db.scans or {}

    if type(charKey) ~= "string" or charKey == "" then
        return nil
    end

    db.scans[charKey] = db.scans[charKey] or {}
    local data = db.scans[charKey]

    data.charKey = charKey
    data.professions = data.professions or {}
    data.updatedAt = tonumber(data.updatedAt) or 0

    return data
end

local function BuildProfessionContextFromRecipeID(recipeID)
    recipeID = ScrubSecret(recipeID)
    if type(recipeID) ~= "number" or recipeID <= 0 or not C_TradeSkillUI then
        return nil
    end

    local charKey = GetCharacterKey()
    if not charKey then
        return nil
    end

    local skillLineID = tonumber(GetSelectedSkillLineID())
    local professionID = nil
    local professionName = nil
    local professionGroupName = nil
    local professionIcon = nil

    if C_TradeSkillUI.GetProfessionInfoByRecipeID then
        local okProf, professionInfo = pcall(C_TradeSkillUI.GetProfessionInfoByRecipeID, recipeID)
        if okProf and type(professionInfo) == "table" then
            skillLineID = skillLineID
                or tonumber(ScrubSecret(professionInfo.professionID))
                or tonumber(ScrubSecret(professionInfo.skillLineID))
                or tonumber(ScrubSecret(professionInfo.parentProfessionID))

            professionID = tonumber(ScrubSecret(professionInfo.profession))
                or tonumber(ScrubSecret(professionInfo.parentProfessionID))

            professionName = professionInfo.professionName
                or professionInfo.skillLineName
                or professionInfo.name
            professionGroupName = ResolveProfessionGroupName(professionInfo, professionName)

            professionIcon = professionInfo.professionIcon
                or professionInfo.icon
        end
    end

    if skillLineID and (not professionName or not professionIcon) then
        local metaName, metaIcon = GetProfessionMeta(skillLineID)
        professionName = professionName or metaName
        professionIcon = professionIcon or metaIcon
    end

    if not skillLineID or skillLineID <= 0 then
        return nil
    end

    professionID, professionGroupName, professionIcon = GetProfessionGroupMeta(skillLineID, professionID, professionGroupName, professionName, professionIcon)

    return {
        charKey = charKey,
        recipeID = recipeID,
        skillLineID = skillLineID,
        professionID = professionID,
        professionName = professionName or T("RL_UNKNOWN_PROFESSION", "Unknown profession"),
        professionGroupName = professionGroupName or professionName or T("RL_UNKNOWN_PROFESSION", "Unknown profession"),
        professionIcon = professionIcon or 136243,
        recipeIDs = nil,
        cachedAt = time(),
    }
end

local function BuildRecipeProfessionContext(recipeID, fallbackContext)
    recipeID = ScrubSecret(recipeID)
    if type(recipeID) ~= "number" or recipeID <= 0 then
        return nil
    end

    local skillLineID = nil
    local professionID = nil
    local professionName = nil
    local professionGroupName = nil
    local professionIcon = nil

    if C_TradeSkillUI and C_TradeSkillUI.GetProfessionInfoByRecipeID then
        local okProf, professionInfo = pcall(C_TradeSkillUI.GetProfessionInfoByRecipeID, recipeID)
        if okProf and type(professionInfo) == "table" then
            skillLineID = tonumber(ScrubSecret(professionInfo.professionID))
                or tonumber(ScrubSecret(professionInfo.skillLineID))
                or tonumber(ScrubSecret(professionInfo.parentProfessionID))

            professionID = tonumber(ScrubSecret(professionInfo.profession))
                or tonumber(ScrubSecret(professionInfo.parentProfessionID))

            professionName = professionInfo.professionName
                or professionInfo.skillLineName
                or professionInfo.name

            professionGroupName = ResolveProfessionGroupName(professionInfo, professionName)
            professionIcon = professionInfo.professionIcon or professionInfo.icon
        end
    end

    skillLineID = skillLineID or tonumber(fallbackContext and fallbackContext.skillLineID)
    professionID = professionID or tonumber(fallbackContext and fallbackContext.professionID)
    professionName = professionName or (fallbackContext and fallbackContext.professionName)
    professionGroupName = professionGroupName or (fallbackContext and fallbackContext.professionGroupName)
    professionIcon = professionIcon or (fallbackContext and fallbackContext.professionIcon)

    if not skillLineID or skillLineID <= 0 then
        return nil
    end

    if not professionName or not professionIcon then
        local metaName, metaIcon = GetProfessionMeta(skillLineID)
        professionName = professionName or metaName
        professionIcon = professionIcon or metaIcon
    end

    professionID, professionGroupName, professionIcon = GetProfessionGroupMeta(
        skillLineID,
        professionID,
        professionGroupName,
        professionName,
        professionIcon
    )

    return {
        skillLineID = skillLineID,
        professionID = professionID,
        professionName = professionName or T("RL_UNKNOWN_PROFESSION", "Unknown profession"),
        professionGroupName = professionGroupName or professionName or T("RL_UNKNOWN_PROFESSION", "Unknown profession"),
        professionIcon = professionIcon or 136243,
    }
end

local function CacheCurrentProfessionContext(recipeID)
    local context = BuildProfessionContextFromRecipeID(recipeID)
    if context then
        M.currentProfessionContext = context
    end
    return context
end

local function CaptureLiveProfessionContext()
    if not C_TradeSkillUI or not C_TradeSkillUI.GetAllRecipeIDs then
        return nil, "missing_api"
    end

    local charKey = GetCharacterKey()
    if not charKey then
        return nil, "no_char"
    end

    if C_TradeSkillUI.IsTradeSkillReady and not C_TradeSkillUI.IsTradeSkillReady() then
        return nil, "not_ready"
    end

    local okIDs, recipeIDs = pcall(C_TradeSkillUI.GetAllRecipeIDs)
    if not okIDs or type(recipeIDs) ~= "table" or #recipeIDs == 0 then
        return nil, "no_recipes"
    end

    local context = nil
    local form = GetProfessionForm()
    if form and type(form.GetRecipeInfo) == "function" then
        local selectedInfo = form:GetRecipeInfo()
        if type(selectedInfo) == "table" and selectedInfo.recipeID then
            context = CacheCurrentProfessionContext(selectedInfo.recipeID)
        end
    end

    if not context and M.currentProfessionContext and M.currentProfessionContext.charKey == charKey then
        context = {
            charKey = M.currentProfessionContext.charKey,
            recipeID = M.currentProfessionContext.recipeID,
            skillLineID = M.currentProfessionContext.skillLineID,
            professionID = M.currentProfessionContext.professionID,
            professionName = M.currentProfessionContext.professionName,
            professionGroupName = M.currentProfessionContext.professionGroupName,
            professionIcon = M.currentProfessionContext.professionIcon,
            recipeIDs = nil,
            cachedAt = M.currentProfessionContext.cachedAt,
        }
    end

    if not context then
        for _, rawRecipeID in ipairs(recipeIDs) do
            local recipeID = ScrubSecret(rawRecipeID)
            if type(recipeID) == "number" and recipeID > 0 then
                context = CacheCurrentProfessionContext(recipeID)
                if context then
                    break
                end
            end
        end
    end

    if not context and C_TradeSkillUI.GetChildProfessionInfo then
        local okChild, childInfo = pcall(C_TradeSkillUI.GetChildProfessionInfo)
        if okChild and type(childInfo) == "table" then
            local skillLineID = tonumber(GetSelectedSkillLineID())
                or tonumber(ScrubSecret(childInfo.professionID))
                or tonumber(ScrubSecret(childInfo.skillLineID))
                or tonumber(ScrubSecret(childInfo.parentProfessionID))

            if skillLineID and skillLineID > 0 then
                local professionName = childInfo.professionName
                    or childInfo.skillLineName
                    or childInfo.parentProfessionName
                    or childInfo.name
                local professionGroupName = childInfo.parentProfessionName
                    or childInfo.parentSkillLineName
                    or professionName

                local professionIcon = childInfo.professionIcon
                    or childInfo.icon

                if not professionName or not professionIcon then
                    local metaName, metaIcon = GetProfessionMeta(skillLineID)
                    professionName = professionName or metaName
                    professionIcon = professionIcon or metaIcon
                end

                local resolvedProfessionID, resolvedGroupName, resolvedIcon = GetProfessionGroupMeta(
                    skillLineID,
                    tonumber(ScrubSecret(childInfo.profession)) or tonumber(ScrubSecret(childInfo.parentProfessionID)),
                    professionGroupName,
                    professionName,
                    professionIcon
                )

                context = {
                    charKey = charKey,
                    recipeID = nil,
                    skillLineID = skillLineID,
                    professionID = resolvedProfessionID,
                    professionName = professionName or T("RL_UNKNOWN_PROFESSION", "Unknown profession"),
                    professionGroupName = resolvedGroupName or professionName or T("RL_UNKNOWN_PROFESSION", "Unknown profession"),
                    professionIcon = resolvedIcon or 136243,
                    recipeIDs = nil,
                    cachedAt = time(),
                }

                M.currentProfessionContext = context
            end
        end
    end

    if not context then
        return nil, "no_skillline"
    end

    context.recipeIDs = recipeIDs
    context.charKey = charKey
    M.currentProfessionContext = context
    return context, nil
end

local function GetCurrentProfessionSnapshotContext()
    return CaptureLiveProfessionContext()
end

local function EnsureSnapshotWaitFrame()
    if M.snapshotWaitFrame then
        return M.snapshotWaitFrame
    end

    local f = CreateFrame("Frame")
    f:RegisterEvent("TRADE_SKILL_LIST_UPDATE")
    f:RegisterEvent("TRADE_SKILL_SHOW")
    f:RegisterEvent("TRADE_SKILL_CLOSE")
    f:RegisterEvent("CRAFTING_DETAILS_UPDATE")
    f:SetScript("OnEvent", function(_, event)
        if event == "TRADE_SKILL_CLOSE" then
            M.pendingSnapshot = nil
            if M.frame and M.frame:IsShown() then
                M:RefreshWindow()
            end
            return
        end

        if not M.pendingSnapshot then
            return
        end

        M:EnsureProfessionHooks()
        PrimeCurrentProfessionContextFromUI()

        local context, reason = GetCurrentProfessionSnapshotContext()
        if context then
            M.pendingSnapshot = nil
            M:ScanCurrentProfession(true)
            return
        end

        if reason ~= "not_ready" and reason ~= "no_recipes" and reason ~= "no_skillline" then
            M.pendingSnapshot = nil
            print(T("RL_SCAN_NEED_PROF", "AhUI: open the profession window and select a profession first"))
        end
    end)

    M.snapshotWaitFrame = f
    return f
end

local function GetCharScanData(charKey)
    return NormalizeCharScanData(charKey)
end

local function GetCharacterLastSnapshotTime(charKey)
    local data = GetCharScanData(charKey)
    if not data then
        return 0
    end

    local ts = tonumber(data.updatedAt) or 0
    for _, professionData in pairs(data.professions or {}) do
        ts = math.max(ts, tonumber(professionData.scannedAt) or 0)
    end
    return ts
end

local function GetScannedProfessionCount(charKey)
    local count = 0
    local data = GetCharScanData(charKey)
    if not data then
        return 0
    end

    for _ in pairs(data.professions or {}) do
        count = count + 1
    end

    return count
end

local function GetScannedRecipeCount(charKey)
    local count = 0
    local data = GetCharScanData(charKey)
    if not data then
        return 0
    end

    for _, professionData in pairs(data.professions or {}) do
        count = count + #(professionData.recipes or {})
    end

    return count
end

local function GetScannedProfessionGroupsForCharacter(charKey)
    local rows = {}
    local groups = {}
    local data = GetCharScanData(charKey)
    if not data then
        return rows
    end

    for rawSkillLineID, professionData in pairs(data.professions or {}) do
        local skillLineID = tonumber(rawSkillLineID) or tonumber(professionData and professionData.skillLineID) or 0
        local groupKey = GetProfessionGroupKey(
            professionData and professionData.professionID,
            professionData and professionData.professionGroupName,
            professionData and professionData.professionName,
            skillLineID
        )
        local resolvedProfessionID, resolvedGroupName, resolvedIcon = GetProfessionGroupMeta(
            skillLineID,
            professionData and professionData.professionID,
            professionData and professionData.professionGroupName,
            professionData and professionData.professionName,
            professionData and professionData.professionIcon
        )

        local fallbackProfessionName, fallbackProfessionIcon = GetProfessionMeta(skillLineID)

        local displayProfessionName =
            resolvedGroupName
            or (professionData and professionData.professionGroupName)
            or (professionData and professionData.professionName)
            or fallbackProfessionName
            or T("RL_UNKNOWN_PROFESSION", "Unknown profession")

        local nameBasedProfessionIcon = GetProfessionIconByName(displayProfessionName)

        local storedProfessionIcon = professionData and professionData.professionIcon or nil
        local safeStoredIcon = (storedProfessionIcon and not IsGenericProfessionIcon(storedProfessionIcon)) and storedProfessionIcon or nil
        local safeFallbackIcon = (fallbackProfessionIcon and not IsGenericProfessionIcon(fallbackProfessionIcon)) and fallbackProfessionIcon or nil
        local safeResolvedIcon = (resolvedIcon and not IsGenericProfessionIcon(resolvedIcon)) and resolvedIcon or nil

        local displayProfessionIcon =
            nameBasedProfessionIcon
            or safeStoredIcon
            or safeFallbackIcon
            or safeResolvedIcon
            or storedProfessionIcon
            or fallbackProfessionIcon
            or resolvedIcon
            or 136243

        local group = groups[groupKey]
        if not group then
            group = {
                key = groupKey,
                charKey = charKey,
                professionID = tonumber(resolvedProfessionID),
                professionName = displayProfessionName,
                icon = displayProfessionIcon,
                recipeCount = 0,
                snapshotCount = 0,
                lastSnapshot = 0,
            }
            groups[groupKey] = group
            table.insert(rows, group)
        end

        group.recipeCount = group.recipeCount + #(professionData.recipes or {})
        group.snapshotCount = group.snapshotCount + 1
        group.lastSnapshot = math.max(group.lastSnapshot or 0, tonumber(professionData.scannedAt) or 0)
    end

    table.sort(rows, function(a, b)
        local an = a.professionName or ""
        local bn = b.professionName or ""
        if an == bn then
            return (a.professionID or 0) < (b.professionID or 0)
        end
        return an < bn
    end)

    return rows
end

local function GetScannedRecipesForProfessionGroup(charKey, professionGroupKey)
    local rows = {}
    local data = GetCharScanData(charKey)
    if not data or not professionGroupKey then
        return rows
    end

    for rawSkillLineID, professionData in pairs(data.professions or {}) do
        local skillLineID = tonumber(rawSkillLineID) or tonumber(professionData and professionData.skillLineID) or 0
        local groupKey = GetProfessionGroupKey(
            professionData and professionData.professionID,
            professionData and professionData.professionGroupName,
            professionData and professionData.professionName,
            skillLineID
        )

        if groupKey == professionGroupKey then
            local _, professionGroupName, professionIcon = GetProfessionGroupMeta(
                skillLineID,
                professionData and professionData.professionID,
                professionData and professionData.professionGroupName,
                professionData and professionData.professionName,
                professionData and professionData.professionIcon
            )
            local professionName = professionData.professionName

            if not professionName then
                professionName, professionIcon = GetProfessionMeta(skillLineID)
            end

            for _, recipe in ipairs(professionData.recipes or {}) do
                local recipeID = tonumber(recipe.recipeID)
                if recipeID and recipeID > 0 then
                    table.insert(rows, {
                        charKey = charKey,
                        skillLineID = tonumber(recipe.skillLineID) or skillLineID,
                        professionID = tonumber(recipe.professionID) or tonumber(professionData.professionID),
                        professionName = recipe.professionName or professionName,
                        professionGroupName = professionGroupName or recipe.professionGroupName or professionData.professionGroupName or professionData.professionName,
                        professionIcon = recipe.professionIcon or professionIcon,
                        recipeID = recipeID,
                        recipeName = recipe.recipeName or (GetSpellInfo and GetSpellInfo(recipeID)) or ("spell:" .. tostring(recipeID)),
                        icon = recipe.icon or GetSpellIcon(recipeID),
                        reagents = recipe.reagents or {},
                        scannedAt = tonumber(recipe.scannedAt) or tonumber(professionData.scannedAt) or 0,
                        epochSkillLineID = tonumber(recipe.epochSkillLineID) or tonumber(professionData.skillLineID) or skillLineID,
                        epochName = recipe.epochName or professionData.professionName or T("RL_FILTER_UNKNOWN", "Unknown"),
                        categoryID = tonumber(recipe.categoryID) or 0,
                        categoryName = recipe.categoryName or T("RL_FILTER_UNCATEGORIZED", "Uncategorized"),
                        selectionKey = string.format("%s:%d:%d", charKey, tonumber(recipe.skillLineID) or skillLineID or 0, recipeID),
                    })
                end
            end
        end
    end

    table.sort(rows, function(a, b)
        local ae = a.epochName or ""
        local be = b.epochName or ""
        if ae ~= be then
            return ae < be
        end

        local ac = a.categoryName or ""
        local bc = b.categoryName or ""
        if ac ~= bc then
            return ac < bc
        end

        local an = a.recipeName or ""
        local bn = b.recipeName or ""
        if an ~= bn then
            return an < bn
        end

        return (a.recipeID or 0) < (b.recipeID or 0)
    end)

    return rows
end

local function GetEpochSortRank(label)
    label = string.lower(tostring(label or ""))
    label = string.gsub(label, "ё", "е")

    local ordered = {
        { 12, { "midnight", "полноч" } },
        { 11, { "kaz algar", "каз алгар", "the war within", "tww", "война внутри", "алгар" } },
        { 10, { "dragonflight", "dragon isles", "драконь", "островов" } },
        { 9,  { "shadowlands", "темных зем", "темные зем" } },
        { 8,  { "battle for azeroth", "bfa", "кул-тирас", "кул тирас", "азерот" } },
        { 7,  { "legion", "легион" } },
        { 6,  { "warlords of draenor", "draenor", "wod", "дренор" } },
        { 5,  { "mists of pandaria", "pandaria", "mop", "пандар" } },
        { 4,  { "cataclysm", "катаклизм" } },
        { 3,  { "wrath of the lich king", "wotlk", "northrend", "нордскол", "короля-лича", "короля лича" } },
        { 2,  { "the burning crusade", "burning crusade", "outland", "bc", "запредель" } },
        { 1,  { "classic", "классичес", "ванилл" } },
    }

    for _, entry in ipairs(ordered) do
        local rank = entry[1]
        local keys = entry[2]

        for _, key in ipairs(keys) do
            if string.find(label, key, 1, true) then
                return rank
            end
        end
    end

    return 0
end

local function BuildFilterOptions(rows, valueKey, labelKey)
    local options = {
        { value = "all", label = T("RL_FILTER_ALL", "All") },
    }
    local seen = { all = true }

    for _, row in ipairs(rows or {}) do
        local value = row[valueKey]
        local label = row[labelKey]
        if value ~= nil then
            value = tostring(value)
            if value ~= "" and not seen[value] then
                seen[value] = true
                table.insert(options, {
                    value = value,
                    label = (type(label) == "string" and label ~= "") and label or value,
                })
            end
        end
    end

    table.sort(options, function(a, b)
        if a.value == "all" then return true end
        if b.value == "all" then return false end

        if valueKey == "epochSkillLineID" then
            local ar = GetEpochSortRank(a.label)
            local br = GetEpochSortRank(b.label)
            if ar ~= br then
                return ar > br
            end
        end

        return (a.label or "") < (b.label or "")
    end)

    return options
end

local function FindFilterOption(options, value)
    for index, option in ipairs(options or {}) do
        if tostring(option.value) == tostring(value) then
            return index, option
        end
    end
    return nil, nil
end

local function CycleFilterValue(options, currentValue, step)
    if not options or #options == 0 then
        return "all"
    end

    local currentIndex = 1
    for index, option in ipairs(options or {}) do
        if tostring(option.value) == tostring(currentValue) then
            currentIndex = index
            break
        end
    end

    currentIndex = currentIndex + (step or 1)
    if currentIndex < 1 then
        currentIndex = #options
    elseif currentIndex > #options then
        currentIndex = 1
    end

    return options[currentIndex] and options[currentIndex].value or "all"
end

local function ShowFilterDropdown(owner, title, options, currentValue, onSelect)
    if not PT.Dropdown or type(PT.Dropdown.Show) ~= "function" then
        return
    end

    local items = {}
    table.insert(items, {
        text = title or "",
        header = true,
    })

    for _, option in ipairs(options or {}) do
        table.insert(items, {
            text = option.label or tostring(option.value),
            checked = tostring(option.value) == tostring(currentValue),
            onClick = function()
                if onSelect then
                    onSelect(option.value)
                end
            end,
        })
    end

    PT.Dropdown:Show({
        owner = owner,
        items = items,
    })
end

local function GetSelectedScannedRecipe()
    local charKey = M.state and M.state.scanSelectedCharKey
    local selectionKey = M.state and M.state.scanSelectedRecipeKey
    if not charKey or not selectionKey then
        return nil
    end

    for _, group in ipairs(GetScannedProfessionGroupsForCharacter(charKey)) do
        for _, row in ipairs(GetScannedRecipesForProfessionGroup(charKey, group.key)) do
            if row.selectionKey == selectionKey then
                return row
            end
        end
    end

    return nil
end

local function SetWidgetListShown(list, shown)
    for _, widget in ipairs(list or {}) do
        if widget then
            widget:SetShown(shown)
        end
    end
end

local function ApplyMainTabVisibility(frame, activeTab)
    SetWidgetListShown(frame.scanTabWidgets, activeTab == 1)
    SetWidgetListShown(frame.queueTabWidgets, activeTab == 2)

    if frame.tabChars then
        frame.tabChars:SetEnabled(activeTab ~= 1)
    end
    if frame.tabQueue then
        frame.tabQueue:SetEnabled(activeTab ~= 2)
    end
end

local function DeepCopy(value)
    if type(value) ~= "table" then
        return value
    end

    local out = {}
    for k, v in pairs(value) do
        out[k] = DeepCopy(v)
    end
    return out
end

local function GetBagMaxIndex()
    local extra = tonumber(NUM_REAGENTBAG_SLOTS) or 0
    return (tonumber(NUM_BAG_SLOTS) or 4) + extra
end

local function IteratePlayerBags(callback)
    for bag = 0, GetBagMaxIndex() do
        local slots = C_Container.GetContainerNumSlots(bag) or 0
        for slot = 1, slots do
            local stop = callback(bag, slot)
            if stop then
                return
            end
        end
    end
end

local function GetPlayerOwnedItemCount(itemID)
    itemID = tonumber(itemID)
    if not itemID or itemID <= 0 then
        return 0
    end

    if C_Item and C_Item.GetItemCount then
        local ok, count = pcall(C_Item.GetItemCount, itemID, true, false, true, true)
        if ok and type(count) == "number" then
            return math.max(0, count)
        end

        ok, count = pcall(C_Item.GetItemCount, itemID, true, false, true)
        if ok and type(count) == "number" then
            return math.max(0, count)
        end
    end

    if GetItemCount then
        local ok, count = pcall(GetItemCount, itemID, true, false, true)
        if ok and type(count) == "number" then
            return math.max(0, count)
        end
    end

    return 0
end

local function GetPlayerBagInventory()
    return {}
end

local function GetItemName(itemID)
    if not itemID then return "?" end
    return (C_Item and C_Item.GetItemNameByID and C_Item.GetItemNameByID(itemID)) or GetItemInfo(itemID) or ("item:" .. tostring(itemID))
end

local function GetItemIcon(itemID)
    local _, _, _, _, icon = GetItemInfoInstant(itemID)
    return icon or 134400
end

local function GetSpellIcon(spellID)
    if C_Spell and C_Spell.GetSpellTexture then
        return C_Spell.GetSpellTexture(spellID)
    end
    return GetSpellTexture and GetSpellTexture(spellID) or 136243
end

local function GetAuctionatorAPI()
    local api = Auctionator and Auctionator.API and Auctionator.API.v1
    if type(api) ~= "table" then
        return nil
    end
    if type(api.GetAuctionPriceByItemID) ~= "function" then
        return nil
    end
    return api
end

local function IsAuctionatorPriceAPIAvailable()
    return GetAuctionatorAPI() ~= nil
end

local function GetAuctionatorPriceByItemID(itemID)
    itemID = tonumber(itemID)
    if not itemID or itemID <= 0 then
        return nil
    end

    M._auctionatorItemPriceCache = M._auctionatorItemPriceCache or {}
    local cached = M._auctionatorItemPriceCache[itemID]
    if cached ~= nil then
        return cached or nil
    end

    local api = GetAuctionatorAPI()
    if not api then
        M._auctionatorItemPriceCache[itemID] = false
        return nil
    end

    local ok, price = pcall(api.GetAuctionPriceByItemID, "AhUI", itemID)
    if ok and type(price) == "number" and price > 0 then
        M._auctionatorItemPriceCache[itemID] = price
        return price
    end

    M._auctionatorItemPriceCache[itemID] = false
    return nil
end

local function InvalidateAuctionatorPriceCache()
    M._auctionatorItemPriceCache = {}
end

local function GetCheapestAuctionatorChoice(choices)
    local bestPrice = nil
    local bestItemID = nil
    local bestTier = nil

    for _, opt in ipairs(choices or {}) do
        local itemID = opt and tonumber(opt.itemID) or nil
        if itemID and itemID > 0 then
            local price = GetAuctionatorPriceByItemID(itemID)
            if price and (not bestPrice or price < bestPrice) then
                bestPrice = price
                bestItemID = itemID
                bestTier = opt.tier
            end
        end
    end

    return bestPrice, bestItemID, bestTier
end

local function GetRecipeAuctionatorCost(recipeData)
    if type(recipeData) ~= "table" then
        return nil, false, 0, 0
    end

    local totalCost = 0
    local pricedReagents = 0
    local totalReagents = 0

    for _, reagent in ipairs(recipeData.reagents or {}) do
        local count = math.max(1, tonumber(reagent and reagent.count) or 0)
        if count > 0 then
            totalReagents = totalReagents + 1

            local unitPrice = nil
            if reagent.itemID then
                unitPrice = GetAuctionatorPriceByItemID(reagent.itemID)
            elseif reagent.choices and #reagent.choices > 0 then
                unitPrice = GetCheapestAuctionatorChoice(reagent.choices)
            end

            if unitPrice and unitPrice > 0 then
                totalCost = totalCost + (unitPrice * count)
                pricedReagents = pricedReagents + 1
            end
        end
    end

    if pricedReagents <= 0 then
        return nil, false, 0, totalReagents
    end

    return totalCost, pricedReagents == totalReagents, pricedReagents, totalReagents
end

local function ParseItemIDFromLink(link)
    if type(link) ~= "string" then
        return nil
    end

    local itemID = tonumber(string.match(link, "item:(%d+)"))
    if itemID and itemID > 0 then
        return itemID
    end

    return nil
end

local function ResolveRecipeOutputInfoFromTable(data)
    if type(data) ~= "table" then
        return nil, nil
    end

    local itemID =
        tonumber(data.itemID)
        or tonumber(data.craftedItemID)
        or tonumber(data.productItemID)
        or tonumber(data.outputItemID)
        or tonumber(data.recipeItemID)
        or ParseItemIDFromLink(data.hyperlink)
        or ParseItemIDFromLink(data.itemLink)
        or ParseItemIDFromLink(data.craftedItemLink)
        or ParseItemIDFromLink(data.productItemLink)

    local quantity =
        tonumber(data.quantity)
        or tonumber(data.quantityMin)
        or tonumber(data.outputQuantity)
        or tonumber(data.numMade)
        or tonumber(data.craftedQuantity)
        or tonumber(data.numItemsCreated)
        or tonumber(data.quantityMax)

    if itemID and itemID > 0 then
        return itemID, math.max(1, math.floor(quantity or 1))
    end

    return nil, nil
end

local function GetRecipeOutputInfo(recipeDataOrRecipeID)
    local recipeID = nil
    local storedItemID = nil
    local storedQuantity = nil

    if type(recipeDataOrRecipeID) == "table" then
        recipeID = tonumber(recipeDataOrRecipeID.recipeID)
        storedItemID = tonumber(recipeDataOrRecipeID.outputItemID)
        storedQuantity = tonumber(recipeDataOrRecipeID.outputQuantity)
    else
        recipeID = tonumber(recipeDataOrRecipeID)
    end

    if storedItemID and storedItemID > 0 then
        return storedItemID, math.max(1, math.floor(storedQuantity or 1))
    end

    if not recipeID or recipeID <= 0 or not C_TradeSkillUI then
        return nil, nil
    end

    M._recipeOutputInfoCache = M._recipeOutputInfoCache or {}
    local cached = M._recipeOutputInfoCache[recipeID]
    if cached ~= nil then
        if cached == false then
            return nil, nil
        end
        return cached.itemID, cached.quantity
    end

    local itemID, quantity = nil, nil

    if C_TradeSkillUI.GetRecipeInfo then
        local okInfo, recipeInfo = pcall(C_TradeSkillUI.GetRecipeInfo, recipeID)
        if okInfo and type(recipeInfo) == "table" then
            itemID, quantity = ResolveRecipeOutputInfoFromTable(recipeInfo)
        end
    end

    if (not itemID or itemID <= 0) and C_TradeSkillUI.GetRecipeSchematic then
        local okSche, schematic = pcall(C_TradeSkillUI.GetRecipeSchematic, recipeID, false)
        if okSche and type(schematic) == "table" then
            itemID, quantity = ResolveRecipeOutputInfoFromTable(schematic)

            if (not itemID or itemID <= 0) and type(schematic.outputItemData) == "table" then
                itemID, quantity = ResolveRecipeOutputInfoFromTable(schematic.outputItemData)
            end

            if (not itemID or itemID <= 0) and type(schematic.outputItem) == "table" then
                itemID, quantity = ResolveRecipeOutputInfoFromTable(schematic.outputItem)
            end

            if (not itemID or itemID <= 0) and type(schematic.outputItemInfo) == "table" then
                itemID, quantity = ResolveRecipeOutputInfoFromTable(schematic.outputItemInfo)
            end
        end
    end

    if itemID and itemID > 0 then
        local resolved = {
            itemID = itemID,
            quantity = math.max(1, math.floor(quantity or 1)),
        }
        M._recipeOutputInfoCache[recipeID] = resolved
        return resolved.itemID, resolved.quantity
    end

    M._recipeOutputInfoCache[recipeID] = false
    return nil, nil
end

local function GetRecipeAuctionatorValue(recipeData)
    local itemID, quantity = GetRecipeOutputInfo(recipeData)
    if not itemID or itemID <= 0 then
        return nil, itemID, quantity
    end

    local unitPrice = GetAuctionatorPriceByItemID(itemID)
    if not unitPrice or unitPrice <= 0 then
        return nil, itemID, quantity, unitPrice
    end

    quantity = math.max(1, tonumber(quantity) or 1)
    return unitPrice * quantity, itemID, quantity, unitPrice
end

local function GetRecipeAuctionatorProfit(recipeData)
    local cost, complete, pricedCount, totalCount = GetRecipeAuctionatorCost(recipeData)
    local value, itemID, quantity, unitPrice = GetRecipeAuctionatorValue(recipeData)

    if not cost or not value then
        return nil, cost, value, complete, pricedCount, totalCount, itemID, quantity, unitPrice
    end

    return value - cost, cost, value, complete, pricedCount, totalCount, itemID, quantity, unitPrice
end

local function GetRecipeLiveConcentrationInfo(recipeID)
    recipeID = tonumber(recipeID)
    if not recipeID or recipeID <= 0 then
        return nil
    end

    local form = GetProfessionForm()
    if not form
        or type(form.GetRecipeInfo) ~= "function"
        or type(form.GetRecipeOperationInfo) ~= "function"
    then
        return nil
    end

    local selectedInfo = form:GetRecipeInfo()
    local selectedRecipeID = selectedInfo and tonumber(ScrubSecret(selectedInfo.recipeID)) or nil
    if selectedRecipeID ~= recipeID then
        return nil
    end

    local transaction = form.transaction
    if not transaction or type(transaction.SetApplyConcentration) ~= "function" then
        return nil
    end

    local wasApplying = false
    if type(transaction.IsApplyingConcentration) == "function" then
        local okState, value = pcall(transaction.IsApplyingConcentration, transaction)
        if okState then
            wasApplying = value == true
        end
    else
        wasApplying = transaction.applyConcentration == true
    end

    local okBefore, beforeInfo = pcall(form.GetRecipeOperationInfo, form)
    if not okBefore or type(beforeInfo) ~= "table" then
        return nil
    end

    local okSetTrue = pcall(transaction.SetApplyConcentration, transaction, true)
    if not okSetTrue then
        return nil
    end

    local okAfter, afterInfo = pcall(form.GetRecipeOperationInfo, form)

    pcall(transaction.SetApplyConcentration, transaction, wasApplying)

    if not okAfter or type(afterInfo) ~= "table" then
        return nil
    end

    local beforeQualityID = tonumber(beforeInfo.guaranteedCraftingQualityID) or tonumber(beforeInfo.craftingQualityID)
    local afterQualityID = tonumber(afterInfo.guaranteedCraftingQualityID) or tonumber(afterInfo.craftingQualityID)

    local beforeBonusSkill = tonumber(beforeInfo.bonusSkill) or 0
    local afterBonusSkill = tonumber(afterInfo.bonusSkill) or 0

    local concentrationCost = tonumber(afterInfo.concentrationCost)
        or tonumber(beforeInfo.concentrationCost)
        or 0

    local concentrationCurrencyID = tonumber(afterInfo.concentrationCurrencyID)
        or tonumber(beforeInfo.concentrationCurrencyID)

    return {
        available = concentrationCost > 0,
        cost = concentrationCost,
        currencyID = concentrationCurrencyID,
        beforeQualityID = beforeQualityID,
        afterQualityID = afterQualityID,
        upgradesQuality = beforeQualityID ~= nil and afterQualityID ~= nil and afterQualityID > beforeQualityID or false,
        beforeBonusSkill = beforeBonusSkill,
        afterBonusSkill = afterBonusSkill,
        bonusSkillFromConcentration = afterBonusSkill - beforeBonusSkill,
        beforeQuality = tonumber(beforeInfo.quality),
        afterQuality = tonumber(afterInfo.quality),
        beforeOperationInfo = beforeInfo,
        afterOperationInfo = afterInfo,
    }
end

function GetProfessionForm()
    if not ProfessionsFrame
        or not ProfessionsFrame.CraftingPage
        or not ProfessionsFrame.CraftingPage.SchematicForm
    then
        return nil
    end

    local form = ProfessionsFrame.CraftingPage.SchematicForm
    if form:IsVisible() ~= true then
        return nil
    end

    return form
end

function GetSelectedSkillLineID()
    if G and G.GetSelectedSkillLine then
        local skillLineID = G:GetSelectedSkillLine()
        if skillLineID then
            return ScrubSecret(skillLineID)
        end
    end

    if C_TradeSkillUI and C_TradeSkillUI.GetProfessionChildSkillLineID then
        return ScrubSecret(C_TradeSkillUI.GetProfessionChildSkillLineID())
    end

    return nil
end

local function PrimeCurrentProfessionContextFromUI()
    if not C_TradeSkillUI or not C_TradeSkillUI.GetAllRecipeIDs then
        return nil
    end

    local form = GetProfessionForm()
    if form and type(form.GetRecipeInfo) == "function" then
        local recipeInfo = form:GetRecipeInfo()
        if type(recipeInfo) == "table" and recipeInfo.recipeID then
            local context = CacheCurrentProfessionContext(recipeInfo.recipeID)
            if context then
                local okIDs, recipeIDs = pcall(C_TradeSkillUI.GetAllRecipeIDs)
                if okIDs and type(recipeIDs) == "table" and #recipeIDs > 0 then
                    context.recipeIDs = recipeIDs
                    M.currentProfessionContext = context
                end
            end
            return context
        end
    end

    return nil
end

function M:EnsureProfessionHooks()
    if self.professionHooksInstalled then
        return true
    end

    if not ProfessionsFrame
        or not ProfessionsFrame.CraftingPage
        or not ProfessionsFrame.CraftingPage.SchematicForm
    then
        return false
    end

    local function OnRecipeInit(_, recipeInfo)
        if type(recipeInfo) ~= "table" or not recipeInfo.recipeID then
            return
        end

        local context = CacheCurrentProfessionContext(recipeInfo.recipeID)
        if context and C_TradeSkillUI and C_TradeSkillUI.GetAllRecipeIDs then
            local okIDs, recipeIDs = pcall(C_TradeSkillUI.GetAllRecipeIDs)
            if okIDs and type(recipeIDs) == "table" and #recipeIDs > 0 then
                context.recipeIDs = recipeIDs
                M.currentProfessionContext = context
            end
        end

        if M.pendingSnapshot then
            C_Timer.After(0, function()
                if PT and PT.RecipeList and PT.RecipeList.pendingSnapshot then
                    PT.RecipeList:ScanCurrentProfession(true)
                end
            end)
        end
    end

    hooksecurefunc(ProfessionsFrame.CraftingPage.SchematicForm, "Init", OnRecipeInit)
    self.professionHooksInstalled = true
    return true
end


local function ExtractItemIDs(data)
    local ids = {}
    local seen = {}

    local function Add(itemID)
        itemID = ScrubSecret(itemID)
        if type(itemID) == "number" and itemID > 0 and not seen[itemID] then
            seen[itemID] = true
            table.insert(ids, itemID)
        end
    end

    if type(data) ~= "table" then
        return ids
    end

    Add(data.itemID)
    Add(data.reagentItemID)
    Add(data.reagentID)
    Add(data.item)

    for _, key in ipairs({ "itemIDs", "reagentItemIDs", "items" }) do
        if type(data[key]) == "table" then
            for _, itemID in ipairs(data[key]) do
                Add(itemID)
            end
        end
    end

    return ids
end

local function ShouldIncludeSlot(slot)
    if not slot or not Enum or not Enum.CraftingReagentType then
        return true
    end

    if slot.optional then
        return false
    end

    return slot.reagentType == nil
        or slot.reagentType == Enum.CraftingReagentType.Basic
        or slot.reagentType == Enum.CraftingReagentType.Automatic
end

local function CopyChoices(choices)
    local out = {}
    local seen = {}

    for index, opt in ipairs(choices or {}) do
        local itemID = opt and opt.itemID
        if itemID and not seen[itemID] then
            seen[itemID] = true
            table.insert(out, {
                itemID = itemID,
                tier = opt.tier ~= nil and opt.tier or index,
            })
        end
    end

    table.sort(out, function(a, b)
        local at = tonumber(a.tier) or 0
        local bt = tonumber(b.tier) or 0
        if at == bt then
            return (a.itemID or 0) < (b.itemID or 0)
        end
        return at < bt
    end)

    return out
end

local function GetChoicesLabel(choices)
    local labels = {}
    for _, opt in ipairs(choices or {}) do
        if opt and opt.itemID then
            local label = GetItemName(opt.itemID)
            if opt.tier ~= nil then
                label = string.format("%s (%s %s)", label, T("RL_GRADE_SHORT", "G"), tostring(opt.tier))
            end
            table.insert(labels, label)
        end
    end
    return table.concat(labels, " / ")
end

local function GetChoicesSignature(choices)
    local ids = {}
    for _, opt in ipairs(CopyChoices(choices)) do
        if opt.itemID then
            table.insert(ids, tostring(opt.itemID))
        end
    end
    return table.concat(ids, ",")
end

local function ApplyEntrySelections(entry)
    if type(entry) ~= "table" then
        return
    end

    entry.autoCheap = entry.autoCheap == true

    for _, reagent in ipairs(entry.reagents or {}) do
        local choices = CopyChoices(reagent.choices)
        if #choices > 0 then
            reagent.choices = choices

            if entry.autoCheap then
                reagent.itemID = nil
                reagent.tier = nil
            else
                local selected = nil

                if reagent.itemID then
                    for _, opt in ipairs(choices) do
                        if opt.itemID == reagent.itemID then
                            selected = opt
                            break
                        end
                    end
                end

                if not selected and reagent.tier ~= nil then
                    for _, opt in ipairs(choices) do
                        if opt.tier == reagent.tier then
                            selected = opt
                            break
                        end
                    end
                end

                if not selected then
                    selected = choices[1]
                end

                reagent.itemID = selected and selected.itemID or nil
                reagent.tier = selected and selected.tier or nil
            end
        end
    end
end

local function BuildResolvedReagents(recipeID)
    if not recipeID or not C_TradeSkillUI or not C_TradeSkillUI.GetRecipeSchematic then
        return nil
    end

    local ok, schematic = pcall(C_TradeSkillUI.GetRecipeSchematic, recipeID, false)
    if (not ok or type(schematic) ~= "table" or type(schematic.reagentSlotSchematics) ~= "table")
       and C_TradeSkillUI.OpenRecipe
    then
        pcall(C_TradeSkillUI.OpenRecipe, recipeID)
        ok, schematic = pcall(C_TradeSkillUI.GetRecipeSchematic, recipeID, false)
    end

    if not ok or type(schematic) ~= "table" or type(schematic.reagentSlotSchematics) ~= "table" then
        return nil
    end

    local reagents = {}

    for slotIndex, slot in ipairs(schematic.reagentSlotSchematics or {}) do
        if ShouldIncludeSlot(slot) then
            local options = {}
            local quantity = nil

            for _, reagentData in ipairs(slot.reagents or {}) do
                local slotQuantity = nil

                if type(slot.GetQuantityRequired) == "function" then
                    local okQty, value = pcall(slot.GetQuantityRequired, slot, reagentData)
                    if okQty and type(value) == "number" then
                        slotQuantity = value
                    end
                end

                quantity = quantity
                    or slotQuantity
                    or tonumber(slot.quantityRequired)
                    or tonumber(slot.requiredQuantity)
                    or tonumber(reagentData.quantityRequired)
                    or tonumber(reagentData.quantity)

                local tier = reagentData.tier or reagentData.quality or reagentData.reagentQuality
                for _, itemID in ipairs(ExtractItemIDs(reagentData)) do
                    table.insert(options, {
                        itemID = itemID,
                        tier = tier,
                    })
                end
            end

            if #options == 0 then
                for _, itemID in ipairs(ExtractItemIDs(slot)) do
                    table.insert(options, { itemID = itemID })
                end
            end

            quantity = tonumber(quantity) or 0
            options = CopyChoices(options)

            if quantity > 0 and #options > 0 then
                local reagent = {
                    slotIndex = ScrubSecret(slot.slotIndex) or slotIndex,
                    count = quantity,
                }

                if #options == 1 then
                    reagent.itemID = options[1].itemID
                    reagent.tier = options[1].tier
                else
                    reagent.choices = options
                    reagent.itemID = options[1].itemID
                    reagent.tier = options[1].tier
                end

                table.insert(reagents, reagent)
            end
        end
    end

    if #reagents == 0 then
        return nil
    end

    return reagents
end

local function GetSelectedRecipeInfo()
    local form = GetProfessionForm()
    if not form or type(form.GetRecipeInfo) ~= "function" then
        return nil
    end

    local info = form:GetRecipeInfo()
    if type(info) ~= "table" then
        return nil
    end

    local recipeID = ScrubSecret(info.recipeID)
    if type(recipeID) ~= "number" or recipeID <= 0 then
        return nil
    end

    local skillLineID = GetSelectedSkillLineID()
    local recipeName = info.name

    if not recipeName and C_TradeSkillUI and C_TradeSkillUI.GetRecipeInfo then
        local liveInfo = C_TradeSkillUI.GetRecipeInfo(recipeID)
        recipeName = liveInfo and liveInfo.name
    end

    return {
        recipeID = recipeID,
        skillLineID = skillLineID,
        recipeName = recipeName or (GetSpellInfo and GetSpellInfo(recipeID)) or ("spell:" .. tostring(recipeID)),
        icon = GetSpellIcon(recipeID),
    }
end

local function ConsumeInventory(inventory, itemID, quantity)
    quantity = math.max(0, tonumber(quantity) or 0)
    if not itemID or quantity <= 0 then
        return 0
    end

    if inventory[itemID] == nil then
        inventory[itemID] = GetPlayerOwnedItemCount(itemID)
    end

    local have = tonumber(inventory[itemID]) or 0
    local used = math.min(have, quantity)
    inventory[itemID] = have - used
    return used
end

local function GetHaveCount(reagent, inventory)
    if reagent.itemID then
        if inventory[reagent.itemID] == nil then
            inventory[reagent.itemID] = GetPlayerOwnedItemCount(reagent.itemID)
        end
        return tonumber(inventory[reagent.itemID]) or 0
    end

    local total = 0
    local used = {}
    for _, opt in ipairs(reagent.choices or {}) do
        local itemID = opt and opt.itemID
        if itemID and not used[itemID] then
            used[itemID] = true
            if inventory[itemID] == nil then
                inventory[itemID] = GetPlayerOwnedItemCount(itemID)
            end
            total = total + (tonumber(inventory[itemID]) or 0)
        end
    end
    return total
end

local function ResolveQueueEntryIndex(entry, fallbackIndex)
    local db = GetDB()

    for index, storedEntry in ipairs(db.queue or {}) do
        if storedEntry == entry then
            return index
        end
    end

    return fallbackIndex
end

local function BuildDisplayRows(queue)
    local rows = {}
    local rowMap = {}
    local inventoryTotal = GetPlayerBagInventory()
    local singleEntryView = type(queue) == "table" and #queue == 1

    local function GetAggregateRowKey(itemID, reagent)
        if itemID then
            return "item:" .. tostring(itemID)
        end

        if reagent and reagent.choices and #reagent.choices > 0 then
            return "choices:" .. GetChoicesSignature(reagent.choices)
        end

        return "unknown"
    end

    local function GetChoiceText(entry, reagent)
        if reagent.choices and #reagent.choices > 1 then
            if entry.autoCheap then
                return T("RL_GRADE_AUTO", "Auto")
            elseif reagent.tier ~= nil then
                return tostring(reagent.tier)
            end
        end

        return ""
    end

    for fallbackQueueIndex, entry in ipairs(queue or {}) do
        ApplyEntrySelections(entry)

        local actualQueueIndex = ResolveQueueEntryIndex(entry, fallbackQueueIndex)
        local crafts = math.max(1, tonumber(entry.quantity) or 1)

        for reagentIndex, reagent in ipairs(entry.reagents or {}) do
            local need = (math.max(1, tonumber(reagent.count) or 0)) * crafts
            local itemID = reagent.itemID or (reagent.choices and reagent.choices[1] and reagent.choices[1].itemID) or nil

            if singleEntryView then
                local slotIndex = reagent.slotIndex or reagentIndex

                table.insert(rows, {
                    key = string.format("slot:%d:%d", actualQueueIndex, slotIndex),
                    queueIndex = actualQueueIndex,
                    reagentIndex = reagentIndex,
                    slotIndex = slotIndex,
                    iconItemID = itemID,
                    icon = GetItemIcon(itemID),
                    name = itemID and GetItemName(itemID) or "?",
                    need = need,
                    have = GetHaveCount(reagent, inventoryTotal),
                    enough = false,
                    choiceText = GetChoiceText(entry, reagent),
                    canCycle = reagent.choices and #reagent.choices > 1 and not entry.autoCheap or false,
                    autoCheap = entry.autoCheap == true,
                    reagent = DeepCopy(reagent),
                    trackKey = string.format("slot:%d:%d", actualQueueIndex, slotIndex),
                    sourceCount = 1,
                })
            else
                local key = GetAggregateRowKey(itemID, reagent)
                local row = rowMap[key]

                if not row then
                    row = {
                        key = key,
                        queueIndex = actualQueueIndex,
                        reagentIndex = reagentIndex,
                        slotIndex = reagent.slotIndex or reagentIndex,
                        iconItemID = itemID,
                        icon = GetItemIcon(itemID),
                        name = itemID and GetItemName(itemID) or "?",
                        need = 0,
                        have = GetHaveCount(reagent, inventoryTotal),
                        enough = false,
                        choiceText = GetChoiceText(entry, reagent),
                        canCycle = false,
                        autoCheap = entry.autoCheap == true,
                        reagent = DeepCopy(reagent),
                        trackKey = key,
                        sourceCount = 0,
                    }

                    rowMap[key] = row
                    table.insert(rows, row)
                end

                row.need = row.need + need
                row.sourceCount = row.sourceCount + 1
            end
        end
    end

    if singleEntryView then
        table.sort(rows, function(a, b)
            local as = tonumber(a.slotIndex) or 0
            local bs = tonumber(b.slotIndex) or 0
            if as ~= bs then
                return as < bs
            end
            return (a.name or "") < (b.name or "")
        end)
    else
        table.sort(rows, function(a, b)
            local an = a.name or ""
            local bn = b.name or ""
            if an == bn then
                return (a.iconItemID or 0) < (b.iconItemID or 0)
            end
            return an < bn
        end)
    end

    for _, row in ipairs(rows) do
        row.enough = (tonumber(row.have) or 0) >= (tonumber(row.need) or 0)
    end

    return rows
end

local function MergeMissing(outMap, key, payload, quantity)
    local row = outMap[key]
    if not row then
        row = {
            itemID = payload.itemID,
            options = payload.options and CopyChoices(payload.options) or nil,
            quantity = 0,
            label = payload.label,
        }
        outMap[key] = row
    end
    row.quantity = (row.quantity or 0) + quantity
end

local function BuildMissingMaterialsForQueue(queue)
    local outMap = {}
    local inventory = GetPlayerBagInventory()

    for _, entry in ipairs(queue or {}) do
        ApplyEntrySelections(entry)
        local crafts = math.max(1, tonumber(entry.quantity) or 1)

        for _, reagent in ipairs(entry.reagents or {}) do
            local need = (math.max(1, tonumber(reagent.count) or 0)) * crafts

            if reagent.itemID then
                local missing = need - ConsumeInventory(inventory, reagent.itemID, need)
                if missing > 0 then
                    MergeMissing(outMap, "item:" .. tostring(reagent.itemID), {
                        itemID = reagent.itemID,
                        label = GetItemName(reagent.itemID),
                    }, missing)
                end
            elseif reagent.choices and #reagent.choices > 0 then
                local options = CopyChoices(reagent.choices)
                local remaining = need

                for _, opt in ipairs(options) do
                    if remaining <= 0 then
                        break
                    end
                    if opt and opt.itemID then
                        remaining = remaining - ConsumeInventory(inventory, opt.itemID, remaining)
                    end
                end

                if remaining > 0 then
                    MergeMissing(outMap, "choices:" .. GetChoicesSignature(options), {
                        options = options,
                        label = GetChoicesLabel(options),
                    }, remaining)
                end
            end
        end
    end

    local out = {}
    for _, row in pairs(outMap) do
        table.insert(out, row)
    end

    table.sort(out, function(a, b)
        local an = a.label or ""
        local bn = b.label or ""
        if an == bn then
            local aid = a.itemID or (a.options and a.options[1] and a.options[1].itemID) or 0
            local bid = b.itemID or (b.options and b.options[1] and b.options[1].itemID) or 0
            return aid < bid
        end
        return an < bn
    end)

    return out
end

local function BuildQueueSummary(queue)
    local totalRecipes = #(queue or {})
    local totalCrafts = 0

    for _, entry in ipairs(queue or {}) do
        totalCrafts = totalCrafts + math.max(1, tonumber(entry.quantity) or 1)
    end

    return {
        totalRecipes = totalRecipes,
        totalCrafts = totalCrafts,
    }
end

local function BuildQueueListRows(queue)
    local rows = {}
    local summary = BuildQueueSummary(queue)

    if #(queue or {}) > 0 then
        table.insert(rows, {
            queueIndex = 0,
            isAll = true,
            entry = nil,
            recipeName = T("RL_ALL_RECIPES", "All recipes"),
            icon = ALL_RECIPES_ICON,
            qtyText = tostring(summary.totalCrafts or 0),
            queueSummary = summary,
        })
    end

    for index, entry in ipairs(queue or {}) do
        table.insert(rows, {
            queueIndex = index,
            isAll = false,
            entry = entry,
            recipeName = entry.recipeName or "?",
            sourceCharLabel = entry.sourceCharKey and FormatCharacterLabel(entry.sourceCharKey) or "",
            icon = entry.icon or GetSpellIcon(entry.recipeID),
            qtyText = tostring(entry.quantity or 1),
        })
    end

    return rows
end

local function GetVisibleQueueForDetails(queue)
    local selectedIndex = tonumber(M.state and M.state.selectedIndex) or 0
    if selectedIndex > 0 and queue and queue[selectedIndex] then
        return { queue[selectedIndex] }
    end
    return queue or {}
end

local function HideScrollBar(scrollFrame)
    if not scrollFrame then return end

    local bar = scrollFrame.ScrollBar
    if not bar then return end

    if bar.ScrollUpButton then
        bar.ScrollUpButton:Hide()
        bar.ScrollUpButton:SetAlpha(0)
        bar.ScrollUpButton:EnableMouse(false)
    end

    if bar.ScrollDownButton then
        bar.ScrollDownButton:Hide()
        bar.ScrollDownButton:SetAlpha(0)
        bar.ScrollDownButton:EnableMouse(false)
    end

    if bar.ThumbTexture then
        bar.ThumbTexture:Hide()
        bar.ThumbTexture:SetAlpha(0)
    end

    bar:Hide()
    bar:SetAlpha(0)
    bar:SetWidth(1)
    bar:EnableMouse(false)
    bar.Show = function() end
end

local function SetOwnerFrameBackdrop(frame)
    frame:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    frame:SetBackdropColor(0, 0, 0, 0.94)
    frame:SetBackdropBorderColor(1, 1, 1, 1)
end

local function SaveWindowRect(frame)
    local point, _, relPoint, x, y = frame:GetPoint(1)
    local db = GetDB()
    db.ui.point = point or "CENTER"
    db.ui.relPoint = relPoint or point or "CENTER"
    db.ui.x = x or 0
    db.ui.y = y or 0
    db.ui.width = math.floor(frame:GetWidth())
    db.ui.height = math.floor(frame:GetHeight())
end

local function RestoreWindowRect(frame)
    local db = GetDB()
    frame:ClearAllPoints()
    frame:SetPoint(db.ui.point or "CENTER", UIParent, db.ui.relPoint or db.ui.point or "CENTER", db.ui.x or 0, db.ui.y or 0)
    frame:SetSize(db.ui.width or SIZE_W, db.ui.height or SIZE_H)
    frame:SetScale(db.ui.scale or DEFAULT_SCALE)
end

local function NormalizeQueueSourceCharKey(charKey)
    if type(charKey) == "string" and charKey ~= "" then
        return charKey
    end
    return ""
end

local function FindQueueEntryIndex(recipeID, sourceCharKey)
    local db = GetDB()
    local wantedCharKey = NormalizeQueueSourceCharKey(sourceCharKey)

    for index, entry in ipairs(db.queue) do
        if entry.recipeID == recipeID
            and NormalizeQueueSourceCharKey(entry.sourceCharKey) == wantedCharKey
        then
            return index, entry
        end
    end

    return nil, nil
end

local function NormalizeStoredQueue()
    local db = GetDB()
    local cleaned = {}

    for _, entry in ipairs(db.queue or {}) do
        if type(entry) == "table" and type(entry.recipeID) == "number" and entry.recipeID > 0 then
            local quantity = math.max(1, math.floor(tonumber(entry.quantity) or 1))
            local reagents = {}

            for index, reagent in ipairs(entry.reagents or {}) do
                if type(reagent) == "table" then
                    local row = {
                        slotIndex = reagent.slotIndex or index,
                        count = reagent.count,
                    }

                    if reagent.itemID and reagent.count then
                        row.itemID = reagent.itemID
                        row.tier = reagent.tier
                    end

                    local legacyChoices = reagent.choices or reagent.options or reagent.anyOf
                    if legacyChoices and reagent.count then
                        row.choices = CopyChoices(legacyChoices)
                        if not row.itemID and row.choices[1] then
                            row.itemID = row.choices[1].itemID
                            row.tier = row.choices[1].tier
                        end
                    end

                    if row.count and (row.itemID or (row.choices and #row.choices > 0)) then
                        table.insert(reagents, row)
                    end
                end
            end

            local cleanEntry = {
                recipeID = entry.recipeID,
                skillLineID = entry.skillLineID,
                recipeName = entry.recipeName or ((GetSpellInfo and GetSpellInfo(entry.recipeID)) or ("spell:" .. tostring(entry.recipeID))),
                icon = entry.icon or GetSpellIcon(entry.recipeID),
                quantity = quantity,
                autoCheap = entry.autoCheap == true,
                sourceCharKey = type(entry.sourceCharKey) == "string" and entry.sourceCharKey or nil,
                reagents = reagents,
            }
            ApplyEntrySelections(cleanEntry)
            table.insert(cleaned, cleanEntry)
        end
    end

    db.queue = cleaned
end

local function NormalizeStoredTrackers()
    local db = GetDB()
    local cleaned = {}
    local seen = {}

    for _, tracker in ipairs(db.trackers or {}) do
        local key = type(tracker) == "table" and tracker.key or nil
        if type(key) == "string" and key ~= "" and not seen[key] then
            seen[key] = true
            table.insert(cleaned, {
                key = key,
                recipeID = tonumber(tracker.recipeID),
                slotIndex = tonumber(tracker.slotIndex),
                itemID = tonumber(tracker.itemID),
            })
        end
    end

    db.trackers = cleaned
    db.trackerUI = db.trackerUI or {}
end

local function GetSelectedEntry()
    local db = GetDB()
    local index = tonumber(M.state.selectedIndex) or 0
    if index <= 0 then
        return nil, 0
    end
    return db.queue[index], index
end

local function SetSelectedIndex(index)
    local db = GetDB()
    local count = #db.queue
    if count <= 0 then
        M.state.selectedIndex = 0
        return
    end
    index = math.floor(tonumber(index) or 0)
    index = math.max(0, math.min(count, index))
    M.state.selectedIndex = index
end

local function FindTrackedDisplayRow(tracker)
    local rows = BuildDisplayRows(GetDB().queue)
    for _, row in ipairs(rows) do
        if row.trackKey == tracker.key then
            return row
        end
    end
    return nil
end

local function SaveTrackerWindowRect(frame)
    if not frame then return end
    local point, _, relPoint, x, y = frame:GetPoint(1)
    local db = GetDB()
    db.trackerUI = db.trackerUI or {}
    db.trackerUI.point = point or "CENTER"
    db.trackerUI.relPoint = relPoint or point or "CENTER"
    db.trackerUI.x = x or 0
    db.trackerUI.y = y or 0
    db.trackerUI.width = math.floor(frame:GetWidth())
    db.trackerUI.height = math.floor(frame:GetHeight())
end

local function RestoreTrackerWindowRect(frame)
    local db = GetDB()
    local ui = db.trackerUI or {}
    frame:ClearAllPoints()
    frame:SetPoint(ui.point or "CENTER", UIParent, ui.relPoint or ui.point or "CENTER", ui.x or 340, ui.y or 120)
    frame:SetSize(ui.width or 360, ui.height or 180)
    frame:SetScale(ui.scale or DEFAULT_SCALE)
end

local function EnsureCraftWaitFrame()
    if M.craftWaitFrame then
        return
    end

    M.craftWaitFrame = CreateFrame("Frame")
    M.craftWaitFrame:RegisterEvent("TRADE_SKILL_LIST_UPDATE")
    M.craftWaitFrame:SetScript("OnEvent", function()
        if not M.pendingCraft or not C_TradeSkillUI then
            return
        end

        local pending = M.pendingCraft
        M.pendingCraft = nil

        local ok, err = pcall(function()
            local recipeInfo = C_TradeSkillUI.GetRecipeInfo(pending.recipeID)
            if not recipeInfo then
                error("missing_recipe_info")
            end

            if recipeInfo.isEnchantingRecipe and C_TradeSkillUI.CraftEnchant then
                local target = nil
                for bag = 0, NUM_BAG_SLOTS do
                    local slots = C_Container.GetContainerNumSlots(bag)
                    if slots then
                        for slot = 1, slots do
                            if C_Container.GetContainerItemID(bag, slot) == 38682 then
                                target = ItemLocation:CreateFromBagAndSlot(bag, slot)
                                break
                            end
                        end
                    end
                    if target then break end
                end

                if not target then
                    print(T("RL_ERR_NO_VELLUM", "AhUI: no enchanting vellum found in bags"))
                    return
                end

                C_TradeSkillUI.CraftEnchant(pending.recipeID, pending.count or 1, nil, target)
            else
                C_TradeSkillUI.CraftRecipe(pending.recipeID, pending.count or 1)
            end
        end)

        if not ok and err then
            print(T("RL_ERR_CRAFT", "AhUI: could not start crafting:"), err)
        end
    end)
end

local function CraftRecipeByID(recipeID, skillLineID, count)
    if not recipeID or not C_TradeSkillUI then
        return
    end

    local tryCraft = function()
        local recipeInfo = C_TradeSkillUI.GetRecipeInfo(recipeID)
        if not recipeInfo then
            return false, "missing_recipe_info"
        end

        if recipeInfo.isEnchantingRecipe and C_TradeSkillUI.CraftEnchant then
            local target = nil
            for bag = 0, NUM_BAG_SLOTS do
                local slots = C_Container.GetContainerNumSlots(bag)
                if slots then
                    for slot = 1, slots do
                        if C_Container.GetContainerItemID(bag, slot) == 38682 then
                            target = ItemLocation:CreateFromBagAndSlot(bag, slot)
                            break
                        end
                    end
                end
                if target then break end
            end

            if not target then
                return false, "no_vellum"
            end

            C_TradeSkillUI.CraftEnchant(recipeID, count or 1, nil, target)
            return true
        end

        if C_TradeSkillUI.CraftRecipe then
            C_TradeSkillUI.CraftRecipe(recipeID, count or 1)
            return true
        end

        return false, "craft_api_missing"
    end

    local recipeIDs = C_TradeSkillUI.GetAllRecipeIDs and C_TradeSkillUI.GetAllRecipeIDs()
    local recipeInfo = C_TradeSkillUI.GetRecipeInfo and C_TradeSkillUI.GetRecipeInfo(recipeID) or nil
    if recipeIDs and #recipeIDs > 0 and recipeInfo then
        local ok, err = tryCraft()
        if not ok then
            if err == "no_vellum" then
                print(T("RL_ERR_NO_VELLUM", "AhUI: no enchanting vellum found in bags"))
            else
                print(T("RL_ERR_CRAFT", "AhUI: could not start crafting:"), err)
            end
        end
        return
    end

    M.pendingCraft = {
        recipeID = recipeID,
        count = count or 1,
    }
    EnsureCraftWaitFrame()

    if skillLineID and C_TradeSkillUI.OpenTradeSkill then
        C_TradeSkillUI.OpenTradeSkill(skillLineID)
    else
        print(T("RL_ERR_OPEN_PROF", "AhUI: open the profession window for crafting"))
    end
end

-- Shared helpers for the UI layer
M.Core = {
    T = T,
    GetDB = GetDB,
    GetCharacterKey = GetCharacterKey,
    FormatCharacterLabel = FormatCharacterLabel,
    FormatDateTime = FormatDateTime,
    CollectKnownCharacters = CollectKnownCharacters,
    GetScannedProfessionGroupsForCharacter = GetScannedProfessionGroupsForCharacter,
    GetScannedRecipesForProfessionGroup = GetScannedRecipesForProfessionGroup,
    BuildFilterOptions = BuildFilterOptions,
    FindFilterOption = FindFilterOption,
    ShowFilterDropdown = ShowFilterDropdown,
    GetItemName = GetItemName,
    GetItemIcon = GetItemIcon,
    GetSpellIcon = GetSpellIcon,
    IsAuctionatorPriceAPIAvailable = IsAuctionatorPriceAPIAvailable,
    GetAuctionatorPriceByItemID = GetAuctionatorPriceByItemID,
    GetRecipeAuctionatorCost = GetRecipeAuctionatorCost,
    GetRecipeOutputInfo = GetRecipeOutputInfo,
    GetRecipeAuctionatorValue = GetRecipeAuctionatorValue,
    GetRecipeAuctionatorProfit = GetRecipeAuctionatorProfit,
    GetRecipeLiveConcentrationInfo = GetRecipeLiveConcentrationInfo,
    InvalidateAuctionatorPriceCache = InvalidateAuctionatorPriceCache,
    GetProfessionForm = GetProfessionForm,
    GetSelectedRecipeInfo = GetSelectedRecipeInfo,
    PrimeCurrentProfessionContextFromUI = PrimeCurrentProfessionContextFromUI,
    GetCurrentProfessionSnapshotContext = GetCurrentProfessionSnapshotContext,
    BuildDisplayRows = BuildDisplayRows,
    BuildMissingMaterialsForQueue = BuildMissingMaterialsForQueue,
    BuildQueueSummary = BuildQueueSummary,
    BuildQueueListRows = BuildQueueListRows,
    GetVisibleQueueForDetails = GetVisibleQueueForDetails,
    HideScrollBar = HideScrollBar,
    SetOwnerFrameBackdrop = SetOwnerFrameBackdrop,
    SaveWindowRect = SaveWindowRect,
    RestoreWindowRect = RestoreWindowRect,
    GetSelectedEntry = GetSelectedEntry,
    SetSelectedIndex = SetSelectedIndex,
    FindTrackedDisplayRow = FindTrackedDisplayRow,
    SaveTrackerWindowRect = SaveTrackerWindowRect,
    RestoreTrackerWindowRect = RestoreTrackerWindowRect,
    ApplyMainTabVisibility = ApplyMainTabVisibility,
    Constants = {
        FONT = FONT,
        DEFAULT_SCALE = DEFAULT_SCALE,
        SCALE_STEP = SCALE_STEP,
        SCALE_MIN = SCALE_MIN,
        SCALE_MAX = SCALE_MAX,
        SIZE_W = SIZE_W,
        SIZE_H = SIZE_H,
        MIN_W = MIN_W,
        MIN_H = MIN_H,
        MAX_W = MAX_W,
        MAX_H = MAX_H,
        ROW_H = ROW_H,
        ROW_GAP = ROW_GAP,
        QUEUE_W = QUEUE_W,
        ALL_RECIPES_ICON = ALL_RECIPES_ICON,
        COL_TRACK_X = COL_TRACK_X,
        COL_TRACK_W = COL_TRACK_W,
        COL_REAGENT_X = COL_REAGENT_X,
        COL_REAGENT_W = COL_REAGENT_W,
        COL_GRADE_X = COL_GRADE_X,
        COL_GRADE_W = COL_GRADE_W,
        COL_NEED_X = COL_NEED_X,
        COL_NEED_W = COL_NEED_W,
        COL_HAVE_X = COL_HAVE_X,
        COL_HAVE_W = COL_HAVE_W,
        COL_STATE_X = COL_STATE_X,
        COL_STATE_W = COL_STATE_W,
        DETAILS_MIN_W = DETAILS_MIN_W,
    },
}

function M:ScanCurrentProfession(fromPending)
    self:EnsureProfessionHooks()
    PrimeCurrentProfessionContextFromUI()

    local context, reason = GetCurrentProfessionSnapshotContext()
    if not context then
        if reason == "not_ready" or reason == "no_recipes" or reason == "no_skillline" then
            self.pendingSnapshot = true
            EnsureSnapshotWaitFrame()
            if not fromPending then
                print(T("RL_SCAN_WAITING", "AhUI: waiting for profession data, try again in a moment"))
            end
        else
            print(T("RL_SCAN_NEED_PROF", "AhUI: open the profession window and select a profession first"))
        end

        if self.frame and self.frame:IsShown() then
            self:RefreshWindow()
        end
        return
    end

    self.pendingSnapshot = nil

    local scanTime = time()
    local seen = {}
    local buckets = {}
    local totalRecipes = 0

    for _, rawRecipeID in ipairs(context.recipeIDs or {}) do
        local recipeID = ScrubSecret(rawRecipeID)
        if type(recipeID) == "number" and recipeID > 0 and not seen[recipeID] then
            seen[recipeID] = true

            local okInfo, info = pcall(C_TradeSkillUI.GetRecipeInfo, recipeID)
            if okInfo and type(info) == "table"
               and info.name
               and info.isHeader ~= true
               and info.isDummyRecipe ~= true
               and info.learned ~= false
            then
                local recipeContext = BuildRecipeProfessionContext(recipeID, context)
                if recipeContext and recipeContext.skillLineID then
                    local bucketKey = tostring(recipeContext.skillLineID)
                    local bucket = buckets[bucketKey]

                    if not bucket then
                        bucket = {
                            skillLineID = recipeContext.skillLineID,
                            professionID = recipeContext.professionID,
                            professionName = recipeContext.professionName,
                            professionGroupName = recipeContext.professionGroupName,
                            professionIcon = recipeContext.professionIcon,
                            scannedAt = scanTime,
                            recipes = {},
                        }
                        buckets[bucketKey] = bucket
                    end

                    local categorySnapshot = GetRecipeCategorySnapshot(info, recipeContext)

                    table.insert(bucket.recipes, {
                        recipeID = recipeID,
                        skillLineID = recipeContext.skillLineID,
                        professionID = recipeContext.professionID,
                        professionName = recipeContext.professionName,
                        professionGroupName = recipeContext.professionGroupName,
                        professionIcon = recipeContext.professionIcon,
                        recipeName = info.name,
                        icon = (info and info.icon) or GetSpellIcon(recipeID),
                        reagents = DeepCopy(BuildResolvedReagents(recipeID) or {}),
                        categoryID = categorySnapshot.categoryID,
                        categoryName = categorySnapshot.categoryName,
                        epochSkillLineID = recipeContext.skillLineID,
                        epochName = recipeContext.professionName,
                        scannedAt = scanTime,
                    })

                    totalRecipes = totalRecipes + 1
                end
            end
        end
    end

    if totalRecipes == 0 then
        print(T("RL_SCAN_NOTHING", "AhUI: could not collect recipes from the current profession"))
        if self.frame and self.frame:IsShown() then
            self:RefreshWindow()
        end
        return
    end

    local charData = NormalizeCharScanData(context.charKey)
    charData.updatedAt = scanTime

    for _, bucket in pairs(buckets) do
        table.sort(bucket.recipes, function(a, b)
            local an = a.recipeName or ""
            local bn = b.recipeName or ""
            if an == bn then
                return (a.recipeID or 0) < (b.recipeID or 0)
            end
            return an < bn
        end)

        charData.professions[bucket.skillLineID] = bucket
    end

    self.state.scanSelectedCharKey = context.charKey
    self.state.scanSelectedProfessionKey = GetProfessionGroupKey(
        context.professionID,
        context.professionGroupName,
        context.professionName,
        context.skillLineID
    )
    self.state.scanEpochFilter = "all"
    self.state.scanCategoryFilter = "all"
    self.state.scanSelectedRecipeKey = nil

    if self.frame and self.frame:IsShown() then
        self:RefreshWindow()
    end

    print(string.format(
        T("RL_SCAN_DONE", "AhUI: snapshot updated — %d recipes"),
        totalRecipes
    ))
end

function M:AddScannedRecipeToQueue()
    local selected = GetSelectedScannedRecipe()
    if not selected then
        print(T("RL_SCAN_NEED_SELECTED", "AhUI: select a scanned recipe first"))
        return
    end

    local db = GetDB()
    local sourceCharKey = selected.charKey
    local index, existing = FindQueueEntryIndex(selected.recipeID, sourceCharKey)

    if existing then
        existing.sourceCharKey = sourceCharKey
        existing.quantity = math.max(1, tonumber(existing.quantity) or 1) + 1
        existing.skillLineID = selected.skillLineID
        existing.recipeName = selected.recipeName
        existing.icon = selected.icon
        existing.reagents = DeepCopy(selected.reagents or existing.reagents or {})
        ApplyEntrySelections(existing)
        SetSelectedIndex(index)
    else
        local entry = {
            recipeID = selected.recipeID,
            skillLineID = selected.skillLineID,
            recipeName = selected.recipeName,
            icon = selected.icon,
            quantity = 1,
            autoCheap = false,
            reagents = DeepCopy(selected.reagents or {}),
            sourceCharKey = selected.charKey,
        }
        ApplyEntrySelections(entry)
        table.insert(db.queue, entry)
        SetSelectedIndex(#db.queue)
    end

    if self.PulseQueueTab then
        self:PulseQueueTab(0.2)
    end

    self:RefreshWindow()
    self:RefreshTrackers()
end

function M:AddSelectedRecipeToQueue()
    local selected = GetSelectedRecipeInfo()
    if not selected then
        print(T("RL_ERR_NO_RECIPE", "AhUI: choose a recipe first"))
        return
    end

    local reagents = BuildResolvedReagents(selected.recipeID)
    if not reagents and G and G.GetResolvedRecipeReagents then
        local fallback = G:GetResolvedRecipeReagents(selected.skillLineID, {
            spellID = selected.recipeID,
            autoReagents = true,
        }) or {}
        reagents = {}

        for index, reagent in ipairs(fallback) do
            if reagent.itemID and reagent.count then
                table.insert(reagents, {
                    slotIndex = index,
                    itemID = reagent.itemID,
                    tier = reagent.tier,
                    count = reagent.count,
                })
            elseif (reagent.choices or reagent.options or reagent.anyOf) and reagent.count then
                local choices = CopyChoices(reagent.choices or reagent.options or reagent.anyOf)
                if #choices > 0 then
                    table.insert(reagents, {
                        slotIndex = index,
                        count = reagent.count,
                        choices = choices,
                        itemID = choices[1].itemID,
                        tier = choices[1].tier,
                    })
                end
            end
        end
    end

    local db = GetDB()
    local sourceCharKey = GetCharacterKey()
    local index, existing = FindQueueEntryIndex(selected.recipeID, sourceCharKey)

    if existing then
        existing.sourceCharKey = sourceCharKey
        existing.quantity = math.max(1, tonumber(existing.quantity) or 1) + 1
        existing.skillLineID = selected.skillLineID
        existing.recipeName = selected.recipeName
        existing.icon = selected.icon
        existing.reagents = DeepCopy(reagents or existing.reagents or {})
        ApplyEntrySelections(existing)
        SetSelectedIndex(index)
    else
        local entry = {
            sourceCharKey = sourceCharKey,
            recipeID = selected.recipeID,
            skillLineID = selected.skillLineID,
            recipeName = selected.recipeName,
            icon = selected.icon,
            quantity = 1,
            autoCheap = false,
            reagents = DeepCopy(reagents or {}),
        }
        ApplyEntrySelections(entry)
        table.insert(db.queue, entry)
        SetSelectedIndex(#db.queue)
    end

    local f = self:CreateWindow()
    f:Show()
    self:RefreshWindow()
    self:RefreshTrackers()
end

function M:RemoveSelectedRecipe()
    local db = GetDB()
    local _, index = GetSelectedEntry()
    if not db.queue[index] then
        return
    end

    table.remove(db.queue, index)

    if #db.queue <= 0 then
        SetSelectedIndex(0)
    elseif index > #db.queue then
        SetSelectedIndex(#db.queue)
    else
        SetSelectedIndex(index)
    end

    self:RefreshWindow()
    self:RefreshTrackers()
end

function M:ClearQueue()
    local db = GetDB()
    db.queue = {}
    SetSelectedIndex(0)
    self:RefreshWindow()
    self:RefreshTrackers()
end

function M:SetSelectedQuantity(value)
    local entry = GetSelectedEntry()
    if not entry then
        return
    end

    entry.quantity = math.max(1, math.floor(tonumber(value) or 1))
    self:RefreshWindow()
    self:RefreshTrackers()
end

function M:AdjustSelectedQuantity(delta)
    local entry = GetSelectedEntry()
    if not entry then
        return
    end

    self:SetSelectedQuantity((entry.quantity or 1) + (delta or 0))
end

function M:SetSelectedAutoCheap(enabled)
    local entry = GetSelectedEntry()
    if not entry then
        return
    end

    entry.autoCheap = enabled == true
    ApplyEntrySelections(entry)
    self:RefreshWindow()
    self:RefreshTrackers()
end

function M:CycleReagentChoice(queueIndex, reagentIndex, step)
    local db = GetDB()
    local entry = db.queue[tonumber(queueIndex) or 0]
    local reagent = entry and entry.reagents and entry.reagents[tonumber(reagentIndex) or 0] or nil
    if not entry or not reagent or entry.autoCheap or not reagent.choices or #reagent.choices <= 1 then
        return
    end

    local currentIndex = 1
    for index, opt in ipairs(reagent.choices) do
        if opt.itemID == reagent.itemID then
            currentIndex = index
            break
        end
    end

    currentIndex = currentIndex + (step or 0)
    if currentIndex < 1 then
        currentIndex = #reagent.choices
    elseif currentIndex > #reagent.choices then
        currentIndex = 1
    end

    local selected = reagent.choices[currentIndex]
    reagent.itemID = selected and selected.itemID or reagent.itemID
    reagent.tier = selected and selected.tier or reagent.tier

    self:RefreshWindow()
    self:RefreshTrackers()
end

function M:CraftSelected(all)
    local entry = GetSelectedEntry()
    if not entry then
        return
    end

    local count = all and (entry.quantity or 1) or 1
    CraftRecipeByID(entry.recipeID, entry.skillLineID, count)
end

function M:OpenShopping()
    local db = GetDB()
    if #db.queue == 0 then
        return
    end

    if not Shopping or not Shopping.IsAvailable or not Shopping:IsAvailable() then
        print(T("RL_ERR_AUCTION", "AhUI: Blizzard auction API is unavailable"))
        return
    end

    local targetQueue = GetVisibleQueueForDetails(db.queue)
    if not targetQueue or #targetQueue == 0 then
        return
    end

    local materials = BuildMissingMaterialsForQueue(targetQueue)
    if not materials or #materials == 0 then
        print(T("RL_NOTHING_TO_BUY", "AhUI: all reagents are already in bags"))
        return
    end

    local selectedEntry = GetSelectedEntry()
    local skillLineID = selectedEntry and selectedEntry.skillLineID or nil

    local ok, reason = Shopping:CreateShoppingSession(materials, skillLineID)
    if not ok then
        print(T("RL_ERR_AUCTION_OPEN", "AhUI: could not open shopping window:"), reason)
    end
end

function M:IsTracked(trackKey)
    local db = GetDB()
    for _, tracker in ipairs(db.trackers or {}) do
        if tracker.key == trackKey then
            return true
        end
    end
    return false
end

function M:ToggleTracker(rowData)
    if not rowData or not rowData.trackKey then
        return
    end

    local db = GetDB()
    for index, tracker in ipairs(db.trackers or {}) do
        if tracker.key == rowData.trackKey then
            table.remove(db.trackers, index)
            self:RefreshWindow()
            self:RefreshTrackers()
            return
        end
    end

    table.insert(db.trackers, {
        key = rowData.trackKey,
        recipeID = rowData.recipeID,
        slotIndex = rowData.slotIndex,
    })

    self:RefreshWindow()
    self:RefreshTrackers()
end

function M:RemoveScannedProfession(charKey, professionGroupKey)
    if type(charKey) ~= "string" or charKey == "" then
        return false
    end
    if type(professionGroupKey) ~= "string" or professionGroupKey == "" then
        return false
    end

    local db = GetDB()
    local charData = db.scans and db.scans[charKey]
    if type(charData) ~= "table" or type(charData.professions) ~= "table" then
        return false
    end

    local removed = 0

    for rawSkillLineID, professionData in pairs(charData.professions) do
        local skillLineID = tonumber(rawSkillLineID) or tonumber(professionData and professionData.skillLineID) or 0
        local groupKey = GetProfessionGroupKey(
            professionData and professionData.professionID,
            professionData and professionData.professionGroupName,
            professionData and professionData.professionName,
            skillLineID
        )

        if groupKey == professionGroupKey then
            charData.professions[rawSkillLineID] = nil
            removed = removed + 1
        end
    end

    if removed <= 0 then
        return false
    end

    local remainingCount = 0
    local updatedAt = 0
    local replacementProfessionKey = nil

    for rawSkillLineID, professionData in pairs(charData.professions) do
        remainingCount = remainingCount + 1
        updatedAt = math.max(updatedAt, tonumber(professionData and professionData.scannedAt) or 0)

        if not replacementProfessionKey then
            local skillLineID = tonumber(rawSkillLineID) or tonumber(professionData and professionData.skillLineID) or 0
            replacementProfessionKey = GetProfessionGroupKey(
                professionData and professionData.professionID,
                professionData and professionData.professionGroupName,
                professionData and professionData.professionName,
                skillLineID
            )
        end
    end

    if remainingCount <= 0 then
        db.scans[charKey] = nil
    else
        charData.updatedAt = updatedAt
    end

    if self.state
        and self.state.scanSelectedCharKey == charKey
        and self.state.scanSelectedProfessionKey == professionGroupKey
    then
        self.state.scanSelectedCharKey = (remainingCount > 0) and charKey or nil
        self.state.scanSelectedProfessionKey = replacementProfessionKey
        self.state.scanEpochFilter = "all"
        self.state.scanCategoryFilter = "all"
        self.state.scanSelectedRecipeKey = nil
    end

    if self.frame and self.frame:IsShown() then
        self:RefreshWindow()
    end

    return true
end

M.state = M.state or {}
M.state.selectedIndex = tonumber(M.state.selectedIndex) or 0
M.state.activeTab = tonumber(M.state.activeTab) or tonumber(GetDB().ui.activeTab) or 2
M.state.scanSelectedCharKey = M.state.scanSelectedCharKey or GetCharacterKey()
M.state.scanSelectedProfessionKey = M.state.scanSelectedProfessionKey or nil
M.state.scanEpochFilter = M.state.scanEpochFilter or "all"
M.state.scanCategoryFilter = M.state.scanCategoryFilter or "all"
M.state.scanSelectedRecipeKey = M.state.scanSelectedRecipeKey or nil
M.state.scanRecipeSortKey = M.state.scanRecipeSortKey or "default"
if M.state.scanRecipeSortAsc == nil then
    M.state.scanRecipeSortAsc = false
end

NormalizeStoredQueue()
NormalizeStoredTrackers()

local function SafeRegisterEvent(frame, eventName)
    if not frame or type(eventName) ~= "string" or eventName == "" then
        return
    end

    if frame:IsEventRegistered(eventName) then
        return
    end

    pcall(frame.RegisterEvent, frame, eventName)
end

local function SafeUnregisterEvent(frame, eventName)
    if not frame or type(eventName) ~= "string" or eventName == "" then
        return
    end

    if not frame:IsEventRegistered(eventName) then
        return
    end

    pcall(frame.UnregisterEvent, frame, eventName)
end

function M:UpdateRuntimeEvents()
    if not self.watcher then
        return
    end

    local needLiveInventoryUpdates =
        (self.frame and self.frame:IsShown())
        or (self.trackerWindow and self.trackerWindow:IsShown())

    local runtimeEvents = {
        "BAG_UPDATE_DELAYED",
        "PLAYERBANKSLOTS_CHANGED",
        "PLAYERREAGENTBANKSLOTS_CHANGED",
        "PLAYER_ACCOUNT_BANK_TAB_SLOTS_CHANGED",
        "BANKFRAME_OPENED",
        "BANKFRAME_CLOSED",
    }

    for _, eventName in ipairs(runtimeEvents) do
        if needLiveInventoryUpdates then
            SafeRegisterEvent(self.watcher, eventName)
        else
            SafeUnregisterEvent(self.watcher, eventName)
        end
    end
end

function M:QueueEnsureButton(attempt)
    attempt = tonumber(attempt) or 1

    if not self.EnsureButton then
        return
    end

    self:EnsureButton()

    if GetProfessionForm() then
        return
    end

    if attempt >= 10 then
        return
    end

    C_Timer.After(0.05, function()
        if not PT or PT.RecipeList ~= M then
            return
        end

        if not ProfessionsFrame or not ProfessionsFrame:IsShown() then
            return
        end

        M:QueueEnsureButton(attempt + 1)
    end)
end

local function RL_DebugPrint(prefix, value, depth, visited)
    depth = depth or 0
    visited = visited or {}

    local indent = string.rep("  ", depth)

    if type(value) ~= "table" then
        print(indent .. tostring(prefix) .. " = " .. tostring(value))
        return
    end

    if visited[value] then
        print(indent .. tostring(prefix) .. " = <visited>")
        return
    end
    visited[value] = true

    print(indent .. tostring(prefix) .. " = {")

    local keys = {}
    for k in pairs(value) do
        table.insert(keys, k)
    end

    table.sort(keys, function(a, b)
        return tostring(a) < tostring(b)
    end)

    for _, k in ipairs(keys) do
        local v = value[k]
        if type(v) == "table" and depth < 4 then
            RL_DebugPrint("[" .. tostring(k) .. "]", v, depth + 1, visited)
        else
            print(string.rep("  ", depth + 1) .. "[" .. tostring(k) .. "] = " .. tostring(v))
        end
    end

    print(indent .. "}")
end

function M:DebugSelectedRecipeConcentration()
    local selected = GetSelectedRecipeInfo()
    if not selected or not selected.recipeID then
        print("AhUI RL DEBUG: no selected recipe")
        return
    end

    local recipeID = selected.recipeID
    print("AhUI RL DEBUG: recipeID =", recipeID, "name =", tostring(selected.recipeName))

    if C_TradeSkillUI and C_TradeSkillUI.GetRecipeInfo then
        local okInfo, info = pcall(C_TradeSkillUI.GetRecipeInfo, recipeID)
        print("AhUI RL DEBUG: GetRecipeInfo ok =", okInfo, "type =", type(info))
        if okInfo and type(info) == "table" then
            RL_DebugPrint("recipeInfo", info)
        end
    end

    if C_TradeSkillUI and C_TradeSkillUI.GetRecipeSchematic then
        local okSche, schematic = pcall(C_TradeSkillUI.GetRecipeSchematic, recipeID, false)
        print("AhUI RL DEBUG: GetRecipeSchematic ok =", okSche, "type =", type(schematic))
        if okSche and type(schematic) == "table" then
            RL_DebugPrint("schematic", schematic)
        end
    end
end

SLASH_AHUIRLDEBUGCONC1 = "/ahuirldebugconc"
SlashCmdList["AHUIRLDEBUGCONC"] = function()
    if PT and PT.RecipeList and PT.RecipeList.DebugSelectedRecipeConcentration then
        PT.RecipeList:DebugSelectedRecipeConcentration()
    end
end

local function RL_DebugPrintKeysByMask(prefix, value, mask)
    mask = string.lower(tostring(mask or ""))

    if type(value) ~= "table" then
        print(tostring(prefix) .. " = <not table>")
        return
    end

    local found = {}
    for k, v in pairs(value) do
        local keyText = string.lower(tostring(k))
        if string.find(keyText, mask, 1, true) then
            table.insert(found, { key = k, value = v })
        end
    end

    table.sort(found, function(a, b)
        return tostring(a.key) < tostring(b.key)
    end)

    if #found == 0 then
        print(tostring(prefix) .. " = <none>")
        return
    end

    print(tostring(prefix) .. " = {")
    for _, entry in ipairs(found) do
        print("  [" .. tostring(entry.key) .. "] = " .. tostring(entry.value))
    end
    print("}")
end

local function RL_DebugGetMethod(obj, methodName)
    if type(obj) ~= "table" then
        return nil
    end

    local fn = obj[methodName]
    if type(fn) == "function" then
        return fn
    end

    local mt = getmetatable(obj)
    local idx = mt and mt.__index
    if type(idx) == "table" and type(idx[methodName]) == "function" then
        return idx[methodName]
    end

    return nil
end

local function RL_DebugCallMethod(obj, label, methodName, ...)
    local fn = RL_DebugGetMethod(obj, methodName)
    if type(fn) ~= "function" then
        print(tostring(label) .. "." .. tostring(methodName) .. " = <missing>")
        return
    end

    local ok, a, b, c, d, e, f = pcall(fn, obj, ...)
    if not ok then
        print(tostring(label) .. "." .. tostring(methodName) .. " error = " .. tostring(a))
        return
    end

    print(
        tostring(label) .. "." .. tostring(methodName) .. " =",
        tostring(a), tostring(b), tostring(c),
        tostring(d), tostring(e), tostring(f)
    )
end

function M:DebugSelectedRecipeConcentrationLive()
    local selected = GetSelectedRecipeInfo()
    if not selected or not selected.recipeID then
        print("AhUI RL DEBUG CONC2: no selected recipe")
        return
    end

    local form = GetProfessionForm()
    print("AhUI RL DEBUG CONC2: recipeID =", tostring(selected.recipeID), "name =", tostring(selected.recipeName))
    print("AhUI RL DEBUG CONC2: form =", tostring(form))

    if not form then
        print("AhUI RL DEBUG CONC2: no profession form")
        return
    end

    local transaction = form.transaction
    print("AhUI RL DEBUG CONC2: transaction =", tostring(transaction))
    print("AhUI RL DEBUG CONC2: concentrate widget =", tostring(form.Concentrate))

    local function DumpTable(label, value, indent, visited, depth)
        indent = indent or ""
        visited = visited or {}
        depth = depth or 0

        if type(value) ~= "table" then
            print(indent .. tostring(label) .. " = " .. tostring(value))
            return
        end

        if visited[value] then
            print(indent .. tostring(label) .. " = <visited>")
            return
        end
        visited[value] = true

        print(indent .. tostring(label) .. " = {")

        local keys = {}
        for k in pairs(value) do
            table.insert(keys, k)
        end
        table.sort(keys, function(a, b)
            return tostring(a) < tostring(b)
        end)

        for _, k in ipairs(keys) do
            local v = value[k]
            if type(v) == "table" and depth < 1 then
                DumpTable("[" .. tostring(k) .. "]", v, indent .. "  ", visited, depth + 1)
            else
                print(indent .. "  [" .. tostring(k) .. "] = " .. tostring(v))
            end
        end

        print(indent .. "}")
    end

    local function TryCall(label, fn, ...)
        if type(fn) ~= "function" then
            print(label .. " = <missing>")
            return nil
        end

        local ok, a, b, c, d, e, f = pcall(fn, ...)
        if not ok then
            print(label .. " error = " .. tostring(a))
            return nil
        end

        print(label .. " =", tostring(a), tostring(b), tostring(c), tostring(d), tostring(e), tostring(f))
        return a, b, c, d, e, f
    end

    local function DumpOperationInfo(prefix)
        if type(form.GetRecipeOperationInfo) ~= "function" then
            print(prefix .. ": GetRecipeOperationInfo missing")
            return
        end

        local ok, opInfo = pcall(form.GetRecipeOperationInfo, form)
        print(prefix .. ": GetRecipeOperationInfo ok =", tostring(ok), "type =", type(opInfo))
        if ok and type(opInfo) == "table" then
            DumpTable(prefix .. ": operationInfo", opInfo)
        end
    end

    if form.Concentrate then
        DumpTable("form.Concentrate", form.Concentrate)

        TryCall("form.Concentrate:IsShown()", form.Concentrate.IsShown, form.Concentrate)
        TryCall("form.Concentrate:IsVisible()", form.Concentrate.IsVisible, form.Concentrate)
        TryCall("form.Concentrate:GetChecked()", form.Concentrate.GetChecked, form.Concentrate)
        TryCall("form.Concentrate:GetText()", form.Concentrate.GetText, form.Concentrate)
        TryCall("form.Concentrate:IsEnabled()", form.Concentrate.IsEnabled, form.Concentrate)
    end

    local wasApplying = false
    if transaction then
        if type(transaction.IsApplyingConcentration) == "function" then
            local ok, value = pcall(transaction.IsApplyingConcentration, transaction)
            if ok then
                wasApplying = value == true
            end
            print("transaction:IsApplyingConcentration() =", tostring(value))
        else
            wasApplying = transaction.applyConcentration == true
            print("transaction.applyConcentration =", tostring(transaction.applyConcentration))
        end
    end

    DumpOperationInfo("BEFORE")

    if transaction and type(transaction.SetApplyConcentration) == "function" then
        local okSet, errSet = pcall(transaction.SetApplyConcentration, transaction, true)
        print("transaction:SetApplyConcentration(true) ok =", tostring(okSet), "err =", tostring(errSet))

        if type(transaction.IsApplyingConcentration) == "function" then
            local okAfter, valueAfter = pcall(transaction.IsApplyingConcentration, transaction)
            print("after set true, transaction:IsApplyingConcentration() =", tostring(valueAfter), "ok =", tostring(okAfter))
        else
            print("after set true, transaction.applyConcentration =", tostring(transaction.applyConcentration))
        end

        DumpOperationInfo("AFTER_TRUE")

        local okRestore, errRestore = pcall(transaction.SetApplyConcentration, transaction, wasApplying)
        print("transaction:SetApplyConcentration(restore) ok =", tostring(okRestore), "err =", tostring(errRestore), "restore =", tostring(wasApplying))

        DumpOperationInfo("AFTER_RESTORE")
    else
        print("transaction:SetApplyConcentration missing")
    end
end

SLASH_AHUIRLDEBUGCONC21 = "/ahuirldebugconc2"
SlashCmdList["AHUIRLDEBUGCONC2"] = function()
    if PT and PT.RecipeList and PT.RecipeList.DebugSelectedRecipeConcentrationLive then
        PT.RecipeList:DebugSelectedRecipeConcentrationLive()
    end
end

M.watcher = M.watcher or CreateFrame("Frame")
M.watcher:RegisterEvent("PLAYER_LOGIN")
M.watcher:RegisterEvent("TRADE_SKILL_SHOW")
M.watcher:RegisterEvent("TRADE_SKILL_CLOSE")

M.watcher:SetScript("OnEvent", function(_, event)
    if event == "PLAYER_LOGIN" then
        M:UpdateRuntimeEvents()
        return
    end

    if event == "TRADE_SKILL_SHOW" then
        if M.QueueEnsureButton then
            M:QueueEnsureButton()
        elseif M.EnsureButton then
            M:EnsureButton()
        end
        return
    end

    if event == "TRADE_SKILL_CLOSE" then
        M.pendingSnapshot = nil
        if M.openButton then
            M.openButton:Hide()
        end
        if M.addButton then
            M.addButton:Hide()
        end
        return
    end

    if event == "BAG_UPDATE_DELAYED"
        or event == "PLAYERBANKSLOTS_CHANGED"
        or event == "PLAYERREAGENTBANKSLOTS_CHANGED"
        or event == "PLAYER_ACCOUNT_BANK_TAB_SLOTS_CHANGED"
        or event == "BANKFRAME_OPENED"
        or event == "BANKFRAME_CLOSED"
    then
        local mainShown = M.frame and M.frame:IsShown()
        local trackerShown = M.trackerWindow and M.trackerWindow:IsShown()

        if not mainShown and not trackerShown then
            return
        end

        if mainShown then
            M:RefreshWindow()
        end

        if trackerShown and M.RefreshTrackers then
            M:RefreshTrackers()
        end

        return
    end
end)