local PT = AhUI
if not PT then return end

PT.RecipeList = PT.RecipeList or {}
local M = PT.RecipeList
local C = M.Core
if not C then return end
local Calc = M.Calculation

local T = C.T
local GetDB = C.GetDB
local GetCharacterKey = C.GetCharacterKey
local FormatCharacterLabel = C.FormatCharacterLabel
local FormatDateTime = C.FormatDateTime
local CollectKnownCharacters = C.CollectKnownCharacters
local GetScannedProfessionGroupsForCharacter = C.GetScannedProfessionGroupsForCharacter
local GetScannedRecipesForProfessionGroup = C.GetScannedRecipesForProfessionGroup
local BuildFilterOptionsBase = C.BuildFilterOptions

local function BuildFilterOptions(rows, valueKey, labelKey)
    return BuildFilterOptionsBase(rows, valueKey, labelKey)
end

local function NormalizeCategoryFilterName(label)
    label = string.lower(tostring(label or ""))
    label = string.gsub(label, "ё", "е")
    label = string.gsub(label, "^%s+", "")
    label = string.gsub(label, "%s+$", "")
    label = string.gsub(label, "%s+", " ")
    return label
end

local function GetCategoryFilterLabel(row)
    local label = row and row.categoryName
    if type(label) ~= "string" or label == "" then
        return T("RL_FILTER_UNCATEGORIZED", "Uncategorized")
    end
    return label
end

local function GetMergedCategoryFilterValue(row)
    return "name:" .. NormalizeCategoryFilterName(GetCategoryFilterLabel(row))
end

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

    for _, row in ipairs(rows or {}) do
        local label = GetCategoryFilterLabel(row)
        local value = GetMergedCategoryFilterValue(row)

        if not seen[value] then
            seen[value] = true
            table.insert(options, {
                value = value,
                label = label,
            })
        end
    end

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

    return options
end

local FindFilterOption = C.FindFilterOption
local ShowFilterDropdown = C.ShowFilterDropdown
local GetItemName = C.GetItemName
local GetItemIcon = C.GetItemIcon
local GetSpellIcon = C.GetSpellIcon
local GetProfessionForm = C.GetProfessionForm
local GetSelectedRecipeInfo = C.GetSelectedRecipeInfo
local PrimeCurrentProfessionContextFromUI = C.PrimeCurrentProfessionContextFromUI
local GetCurrentProfessionSnapshotContext = C.GetCurrentProfessionSnapshotContext
local BuildDisplayRows = C.BuildDisplayRows
local BuildTrackedDisplayRows = C.BuildTrackedDisplayRows
local BuildMissingMaterialsForQueue = C.BuildMissingMaterialsForQueue
local BuildQueueSummary = C.BuildQueueSummary
local BuildQueueListRows = C.BuildQueueListRows
local GetVisibleQueueForDetails = C.GetVisibleQueueForDetails
local HideScrollBar = C.HideScrollBar
local SetOwnerFrameBackdrop = C.SetOwnerFrameBackdrop
local SaveWindowRect = C.SaveWindowRect
local RestoreWindowRect = C.RestoreWindowRect
local GetSelectedEntry = C.GetSelectedEntry
local SetSelectedIndex = C.SetSelectedIndex
local FindTrackedDisplayRow = C.FindTrackedDisplayRow
local SaveTrackerWindowRect = C.SaveTrackerWindowRect
local RestoreTrackerWindowRect = C.RestoreTrackerWindowRect
local ApplyMainTabVisibility = C.ApplyMainTabVisibility

local K = C.Constants
local FONT = K.FONT
local DEFAULT_SCALE = K.DEFAULT_SCALE
local SCALE_STEP = K.SCALE_STEP
local SCALE_MIN = K.SCALE_MIN
local SCALE_MAX = K.SCALE_MAX
local SIZE_W = K.SIZE_W
local SIZE_H = K.SIZE_H
local MIN_W = K.MIN_W
local MIN_H = K.MIN_H
local MAX_W = K.MAX_W
local MAX_H = K.MAX_H
local ROW_H = K.ROW_H
local ROW_GAP = K.ROW_GAP
local QUEUE_W = K.QUEUE_W
local ALL_RECIPES_ICON = K.ALL_RECIPES_ICON
local COL_TRACK_X = K.COL_TRACK_X
local COL_TRACK_W = K.COL_TRACK_W
local COL_REAGENT_X = K.COL_REAGENT_X
local COL_REAGENT_W = K.COL_REAGENT_W
local COL_GRADE_X = K.COL_GRADE_X
local COL_GRADE_W = K.COL_GRADE_W
local COL_NEED_X = K.COL_NEED_X
local COL_NEED_W = K.COL_NEED_W
local COL_HAVE_X = K.COL_HAVE_X
local COL_HAVE_W = K.COL_HAVE_W
local COL_STATE_X = K.COL_STATE_X
local COL_STATE_W = K.COL_STATE_W
local DETAILS_MIN_W = K.DETAILS_MIN_W
local QUEUE_ROW_H = 36

local Shopping = PT.ProfessionGuides_Shopping
local IsAuctionatorPriceAPIAvailable = C.IsAuctionatorPriceAPIAvailable
local GetRecipeAuctionatorCost = C.GetRecipeAuctionatorCost
local GetRecipeAuctionatorValue = C.GetRecipeAuctionatorValue
local GetRecipeAuctionatorProfit = C.GetRecipeAuctionatorProfit
local GetRecipeLiveConcentrationInfo = C.GetRecipeLiveConcentrationInfo

local function GetSelectedRecipeConcentrationSuffix(entry)
    if not entry or not entry.recipeID or not GetRecipeLiveConcentrationInfo then
        return ""
    end

    local info = GetRecipeLiveConcentrationInfo(entry)
    if not info or not info.available then
        return ""
    end

    local cost = math.floor(tonumber(info.cost) or 0)

    local beforeRank = tonumber(info.beforeOperationInfo and info.beforeOperationInfo.craftingQuality)
    local afterRank = tonumber(info.afterOperationInfo and info.afterOperationInfo.craftingQuality)

    if info.upgradesQuality and beforeRank and afterRank then
        return string.format(
            T("RL_CONCENTRATION_SUFFIX_UP", " | Конц.: %d (%d→%d)"),
            cost,
            beforeRank,
            afterRank
        )
    end

    local bonusSkill = math.max(0, tonumber(info.bonusSkillFromConcentration) or 0)
    if bonusSkill > 0 then
        return string.format(
            T("RL_CONCENTRATION_SUFFIX_SKILL", " | Конц.: %d (+%d навык)"),
            cost,
            bonusSkill
        )
    end

    return string.format(
        T("RL_CONCENTRATION_SUFFIX", " | Конц.: %d"),
        cost
    )
end

local function FormatPriceText(value, complete, allowNegative)
    if type(value) ~= "number" then
        return T("RL_PRICE_UNKNOWN", "—")
    end

    local negative = value < 0
    local absValue = math.abs(value)

    if absValue <= 0 and not allowNegative then
        return T("RL_PRICE_UNKNOWN", "—")
    end

    local text = GetCoinTextureString(math.floor(absValue + 0.5), 12) or tostring(math.floor(absValue + 0.5))
    if negative and allowNegative then
        text = "-" .. text
    elseif negative then
        return T("RL_PRICE_UNKNOWN", "—")
    end

    if complete == false then
        return text .. "*"
    end
    return text
end

local function GetRecipeEvaluationSuffix(entry)
    if type(entry) ~= "table" or not IsAuctionatorPriceAPIAvailable or not IsAuctionatorPriceAPIAvailable() then
        return ""
    end

    local cost, complete = nil, false
    if GetRecipeAuctionatorCost then
        local c, full = GetRecipeAuctionatorCost(entry)
        cost, complete = c, full
    end

    local value = nil
    if GetRecipeAuctionatorValue then
        value = GetRecipeAuctionatorValue(entry)
    end

    local profit = nil
    if GetRecipeAuctionatorProfit then
        profit = GetRecipeAuctionatorProfit(entry)
    elseif cost and value then
        profit = value - cost
    end

    if not cost and not value and profit == nil then
        return ""
    end

    return string.format(
        T("RL_SELECTED_PRICE_SUFFIX", " | Value: %s | Cost: %s | Profit: %s"),
        FormatPriceText(value, true),
        FormatPriceText(cost, complete),
        FormatPriceText(profit, complete, true)
    )
end

local function BuildQueueAuctionatorSummary(queue)
    local totalCost = 0
    local totalValue = 0
    local totalProfit = 0
    local hasCost = false
    local hasValue = false
    local hasProfit = false
    local complete = true

    for _, entry in ipairs(queue or {}) do
        if type(entry) == "table" then
            local qty = math.max(1, math.floor(tonumber(entry.quantity) or 1))
            local cost, costComplete = nil, false
            if GetRecipeAuctionatorCost then
                cost, costComplete = GetRecipeAuctionatorCost(entry)
            end
            local value = GetRecipeAuctionatorValue and GetRecipeAuctionatorValue(entry) or nil
            local profit = nil

            if GetRecipeAuctionatorProfit then
                profit = GetRecipeAuctionatorProfit(entry)
            elseif cost and value then
                profit = value - cost
            end

            if cost then
                totalCost = totalCost + (cost * qty)
                hasCost = true
                if costComplete == false then
                    complete = false
                end
            else
                complete = false
            end

            if value then
                totalValue = totalValue + (value * qty)
                hasValue = true
            end

            if profit ~= nil then
                totalProfit = totalProfit + (profit * qty)
                hasProfit = true
            end
        end
    end

    return {
        cost = hasCost and totalCost or nil,
        value = hasValue and totalValue or nil,
        profit = hasProfit and totalProfit or nil,
        complete = complete,
        hasAny = hasCost or hasValue or hasProfit,
    }
end

local function GetQueueAuctionatorSummaryText(queue)
    if not IsAuctionatorPriceAPIAvailable or not IsAuctionatorPriceAPIAvailable() then
        return T("RL_QUEUE_AH_NOT_READY", "Auctionator: not ready")
    end

    local data = BuildQueueAuctionatorSummary(queue)
    if not data.hasAny then
        return T("RL_QUEUE_AH_NO_PRICE", "Auctionator: no price data")
    end

    return string.format(
        T("RL_QUEUE_PRICE_SUMMARY", "Value: %s  |  Cost: %s  |  Profit: %s"),
        FormatPriceText(data.value, true),
        FormatPriceText(data.cost, data.complete),
        FormatPriceText(data.profit, data.complete, true)
    )
end

local function SortScanRecipes(recipes, sortKey, sortAsc)
    if type(recipes) ~= "table" or #recipes <= 1 then
        return
    end

    local function GetMetric(row)
        if sortKey == "price" then
            return tonumber(row and row._auctionatorRecipeCost) or nil
        elseif sortKey == "value" then
            return tonumber(row and row._auctionatorRecipeValue) or nil
        elseif sortKey == "profit" then
            return tonumber(row and row._auctionatorRecipeProfit) or nil
        end
        return nil
    end

    if sortKey ~= "price" and sortKey ~= "value" and sortKey ~= "profit" then
        return
    end

    table.sort(recipes, function(a, b)
        local av = GetMetric(a)
        local bv = GetMetric(b)

        if av ~= bv then
            if av == nil then return false end
            if bv == nil then return true end
            if sortAsc then
                return av < bv
            else
                return av > bv
            end
        end

        local ac = a and a._auctionatorRecipeCostComplete and 1 or 0
        local bc = b and b._auctionatorRecipeCostComplete and 1 or 0
        if ac ~= bc then
            return ac > bc
        end

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

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

local function UpdateScanSortButtonVisual(button, labelText, activeSortKey, buttonSortKey, sortAsc, enabled)
    if not button or not button.label or not button.sortIcon then
        return
    end

    button.label:SetText(labelText or "")
    button.sortIcon:Hide()

    if enabled then
        button.label:SetTextColor(0.9, 0.95, 1)
    else
        button.label:SetTextColor(0.6, 0.6, 0.6)
    end

    if activeSortKey ~= buttonSortKey then
        return
    end

    button.sortIcon:Show()
    if sortAsc == true then
        button.sortIcon:SetAtlas("housing-floor-arrow-down-disabled")
    else
        button.sortIcon:SetAtlas("housing-floor-arrow-down-default")
    end

    if enabled then
        button.sortIcon:SetVertexColor(1, 1, 1, 1)
    else
        button.sortIcon:SetVertexColor(0.55, 0.55, 0.55, 1)
    end
end

StaticPopupDialogs["AHUI_RL_DELETE_SCANNED_PROFESSION"] = {
    text = T("RL_DELETE_SCANNED_PROFESSION_CONFIRM", "Delete saved snapshots for profession \"%s\" on character \"%s\"?"),
    button1 = YES,
    button2 = CANCEL,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
    OnAccept = function(_, data)
        if data and data.charKey and data.professionKey then
            M:RemoveScannedProfession(data.charKey, data.professionKey)
        end
    end,
}

local function ConfirmRemoveScannedProfession(charKey, professionKey, professionName)
    if type(charKey) ~= "string" or charKey == "" then
        return
    end
    if type(professionKey) ~= "string" or professionKey == "" then
        return
    end

    StaticPopup_Show(
        "AHUI_RL_DELETE_SCANNED_PROFESSION",
        professionName or T("RL_UNKNOWN_PROFESSION", "Unknown profession"),
        FormatCharacterLabel(charKey),
        {
            charKey = charKey,
            professionKey = professionKey,
        }
    )
end

local function CreateQueueRow(parent, index)
    local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
    row:SetHeight(QUEUE_ROW_H)
    row:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
    row:SetBackdropColor(1, 1, 1, index % 2 == 0 and 0.04 or 0.07)

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(24, 24)
    row.icon:SetPoint("LEFT", 6, 0)

    row.name = row:CreateFontString(nil, "OVERLAY")
    row.name:SetFont(FONT, 11)
    row.name:SetPoint("TOPLEFT", row.icon, "TOPRIGHT", 8, 0)
    row.name:SetPoint("RIGHT", -48, 0)
    row.name:SetHeight(12)
    row.name:SetJustifyH("LEFT")
    row.name:SetJustifyV("TOP")

    row.source = row:CreateFontString(nil, "OVERLAY")
    row.source:SetFont(FONT, 10)
    row.source:SetPoint("TOPLEFT", row.name, "BOTTOMLEFT", 0, 0)
    row.source:SetPoint("RIGHT", -48, 0)
    row.source:SetHeight(11)
    row.source:SetJustifyH("LEFT")
    row.source:SetJustifyV("TOP")
    row.source:SetTextColor(0.78, 0.82, 0.9)

    row.qty = row:CreateFontString(nil, "OVERLAY")
    row.qty:SetFont(FONT, 11)
    row.qty:SetPoint("RIGHT", -10, 0)
    row.qty:SetWidth(34)
    row.qty:SetJustifyH("RIGHT")

    row:SetScript("OnClick", function(self)
        if self.queueIndex ~= nil then
            SetSelectedIndex(self.queueIndex)
            M:RefreshWindow()
        end
    end)

    row:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")

        if self.isAll and self.queueSummary then
            GameTooltip:SetText(T("RL_ALL_RECIPES", "All recipes"), 1, 1, 1)
            GameTooltip:AddLine(string.format(T("RL_QUEUE_TOTAL_RECIPES", "Recipes in queue: %d"), tonumber(self.queueSummary.totalRecipes) or 0), 0.9, 0.9, 0.9)
            GameTooltip:AddLine(string.format(T("RL_QUEUE_TOTAL_CRAFTS", "Total crafts: %d"), tonumber(self.queueSummary.totalCrafts) or 0), 0.9, 0.9, 0.9)
            GameTooltip:Show()
            return
        end

        if not self.entry then
            GameTooltip:Hide()
            return
        end

        GameTooltip:SetText(self.entry.recipeName or "?", 1, 1, 1)

        if self.entry.sourceCharKey then
            GameTooltip:AddLine(string.format(
                T("RL_QUEUE_TOOLTIP_CRAFTER", "Crafter: %s"),
                FormatCharacterLabel(self.entry.sourceCharKey)
            ), 0.75, 0.9, 1, true)
        end

        GameTooltip:AddLine(string.format(T("RL_QUEUE_ROW_TOOLTIP", "Queued crafts: %d"), tonumber(self.entry.quantity) or 1), 0.9, 0.9, 0.9)
        GameTooltip:Show()
    end)

    row:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    return row
end

local function CreateReagentRow(parent, index)
    local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
    row:SetHeight(ROW_H)
    row:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
    row:SetBackdropColor(1, 1, 1, index % 2 == 0 and 0.04 or 0.07)

    row.trackBtn = CreateFrame("Button", nil, row)
    row.trackBtn:SetSize(18, 18)
    row.trackBtn:SetPoint("CENTER", row, "LEFT", COL_TRACK_X + (COL_TRACK_W * 0.5), 0)

    row.trackIcon = row.trackBtn:CreateTexture(nil, "ARTWORK")
    row.trackIcon:SetAllPoints()
    row.trackIcon:SetAtlas("uitools-icon-search")
    row.trackIcon:SetAlpha(0.65)

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(20, 20)
    row.icon:SetPoint("LEFT", row, "LEFT", COL_REAGENT_X, 0)

    row.name = row:CreateFontString(nil, "OVERLAY")
    row.name:SetFont(FONT, 11)
    row.name:SetPoint("LEFT", row, "LEFT", COL_REAGENT_X + 28, 0)
    row.name:SetWidth(COL_REAGENT_W - 28)
    row.name:SetJustifyH("LEFT")

    row.prevBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
    row.prevBtn:SetSize(18, 18)
    row.prevBtn:SetPoint("LEFT", row, "LEFT", COL_GRADE_X, 0)
    row.prevBtn:SetText("<")

    row.choice = row:CreateFontString(nil, "OVERLAY")
    row.choice:SetFont(FONT, 10, "OUTLINE")
    row.choice:SetPoint("LEFT", row, "LEFT", COL_GRADE_X + 18, 0)
    row.choice:SetWidth(COL_GRADE_W - 36)
    row.choice:SetJustifyH("CENTER")

    row.nextBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
    row.nextBtn:SetSize(18, 18)
    row.nextBtn:SetPoint("LEFT", row, "LEFT", COL_GRADE_X + COL_GRADE_W - 18, 0)
    row.nextBtn:SetText(">")

    row.need = row:CreateFontString(nil, "OVERLAY")
    row.need:SetFont(FONT, 11)
    row.need:SetPoint("LEFT", row, "LEFT", COL_NEED_X, 0)
    row.need:SetWidth(COL_NEED_W)
    row.need:SetJustifyH("RIGHT")

    row.have = row:CreateFontString(nil, "OVERLAY")
    row.have:SetFont(FONT, 11)
    row.have:SetPoint("LEFT", row, "LEFT", COL_HAVE_X, 0)
    row.have:SetWidth(COL_HAVE_W)
    row.have:SetJustifyH("RIGHT")

    row.state = row:CreateTexture(nil, "OVERLAY")
    row.state:SetSize(16, 16)
    row.state:SetPoint("CENTER", row, "LEFT", COL_STATE_X + (COL_STATE_W * 0.5), 0)

    row.trackBtn:SetScript("OnClick", function(self)
        if self.data then
            M:ToggleTracker(self.data)
        end
    end)

    row.prevBtn:SetScript("OnClick", function(self)
        if self.data then
            M:CycleReagentChoice(self.data.queueIndex, self.data.reagentIndex, -1)
        end
    end)

    row.nextBtn:SetScript("OnClick", function(self)
        if self.data then
            M:CycleReagentChoice(self.data.queueIndex, self.data.reagentIndex, 1)
        end
    end)

    row:SetScript("OnEnter", function(self)
        if not self.data then
            return
        end

        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        if self.data.iconItemID then
            GameTooltip:SetItemByID(self.data.iconItemID)
        else
            GameTooltip:SetText(self.data.name or "?", 1, 1, 1)
        end

        if self.data.reagent and self.data.reagent.choices and #self.data.reagent.choices > 1 then
            GameTooltip:AddLine(T("RL_GRADE", "Grade:"), 0.8, 0.8, 0.8)
            for _, opt in ipairs(self.data.reagent.choices) do
                local line = GetItemName(opt.itemID)
                if opt.tier ~= nil then
                    line = string.format("%s (%s %s)", line, T("RL_GRADE_SHORT", "G"), tostring(opt.tier))
                end
                if opt.itemID == self.data.reagent.itemID then
                    GameTooltip:AddLine("• " .. line, 0.2, 1, 0.2)
                else
                    GameTooltip:AddLine("• " .. line, 0.9, 0.9, 0.9)
                end
            end
        end

        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(T("RL_TRACK_TOOLTIP", "Click to add or remove the reagent in the tracker window."), 0.8, 0.95, 1, true)
        GameTooltip:Show()
    end)

    row:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    return row
end

local EnsureRows

EnsureRows = function(frame, bucket, createFunc, count, anchorParent)
    frame[bucket] = frame[bucket] or {}
    local current = #frame[bucket]
    if current >= count then
        return
    end

    for index = current + 1, count do
        local row = createFunc(anchorParent, index)
        if index == 1 then
            row:SetPoint("TOPLEFT", 0, 0)
            row:SetPoint("TOPRIGHT", -4, 0)
        else
            row:SetPoint("TOPLEFT", frame[bucket][index - 1], "BOTTOMLEFT", 0, -ROW_GAP)
            row:SetPoint("TOPRIGHT", frame[bucket][index - 1], "BOTTOMRIGHT", 0, -ROW_GAP)
        end
        frame[bucket][index] = row
    end
end

local function GetScannedCharacterProfessionRows()
    local rows = {}

    for _, charKey in ipairs(CollectKnownCharacters()) do
        for _, group in ipairs(GetScannedProfessionGroupsForCharacter(charKey)) do
            table.insert(rows, {
                key = string.format("%s::%s", charKey, group.key or ""),
                charKey = charKey,
                professionKey = group.key,
                professionName = group.professionName,
                icon = group.icon,
                recipeCount = group.recipeCount,
                snapshotCount = group.snapshotCount,
                lastSnapshot = group.lastSnapshot,
                displayName = string.format("%s - %s", FormatCharacterLabel(charKey), group.professionName or T("RL_UNKNOWN_PROFESSION", "Unknown profession")),
            })
        end
    end

    table.sort(rows, function(a, b)
        local ac = a.charKey == GetCharacterKey() and 0 or 1
        local bc = b.charKey == GetCharacterKey() and 0 or 1
        if ac ~= bc then
            return ac < bc
        end

        local an = a.displayName or ""
        local bn = b.displayName or ""
        if an == bn then
            return (a.recipeCount or 0) > (b.recipeCount or 0)
        end
        return an < bn
    end)

    return rows
end

local function CreateScanCharacterRow(parent, index)
    local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
    row:SetHeight(32)
    row:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
    row:SetBackdropColor(1, 1, 1, index % 2 == 0 and 0.04 or 0.07)
    row:RegisterForClicks("LeftButtonUp", "RightButtonUp")

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(24, 24)
    row.icon:SetPoint("LEFT", 6, 0)

    row.name = row:CreateFontString(nil, "OVERLAY")
    row.name:SetFont(FONT, 11)
    row.name:SetPoint("LEFT", row.icon, "RIGHT", 8, 0)
    row.name:SetPoint("RIGHT", -54, 0)
    row.name:SetJustifyH("LEFT")

    row.meta = row:CreateFontString(nil, "OVERLAY")
    row.meta:SetFont(FONT, 10)
    row.meta:SetPoint("RIGHT", -8, 0)
    row.meta:SetWidth(44)
    row.meta:SetJustifyH("RIGHT")

    row:SetScript("OnClick", function(self, button)
        if not self.data then
            return
        end

        if button == "RightButton" then
            ConfirmRemoveScannedProfession(
                self.data.charKey,
                self.data.professionKey,
                self.data.professionName
            )
            return
        end

        M.state.scanSelectedCharKey = self.data.charKey
        M.state.scanSelectedProfessionKey = self.data.professionKey
        M.state.scanEpochFilter = "all"
        M.state.scanCategoryFilter = "all"
        M.state.scanSelectedRecipeKey = nil
        M:RefreshWindow()
    end)

    row:SetScript("OnEnter", function(self)
        if not self.data then
            return
        end

        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(self.data.displayName or "?", 1, 1, 1)
        GameTooltip:AddLine(string.format(
            T("RL_SCAN_TOOLTIP_CHAR", "Character: %s"),
            FormatCharacterLabel(self.data.charKey)
        ), 0.9, 0.9, 0.9, true)
        GameTooltip:AddLine(string.format(
            T("RL_SCAN_TOOLTIP_PROF_COUNT", "Recipes: %d"),
            tonumber(self.data.recipeCount) or 0
        ), 0.9, 0.9, 0.9, true)
        GameTooltip:AddLine(string.format(
            T("RL_SCAN_TOOLTIP_SNAPSHOTS", "Snapshots: %d"),
            tonumber(self.data.snapshotCount) or 0
        ), 0.9, 0.9, 0.9, true)
        if self.data.lastSnapshot and self.data.lastSnapshot > 0 then
            GameTooltip:AddLine(string.format(
                T("RL_SCAN_TOOLTIP_TIME", "Snapshot: %s"),
                FormatDateTime(self.data.lastSnapshot)
            ), 0.75, 0.9, 1, true)
        end
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(T("RL_SCAN_TOOLTIP_REMOVE_PROF", "Right-click to delete saved snapshots for this profession."), 1, 0.3, 0.3, true)
        GameTooltip:Show()
    end)

    row:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    return row
end

local function CreateScanRecipeRow(parent, index)
    local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
    row:SetHeight(34)
    row:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
    row:SetBackdropColor(1, 1, 1, index % 2 == 0 and 0.04 or 0.07)

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(24, 24)
    row.icon:SetPoint("LEFT", 6, 0)

    row.name = row:CreateFontString(nil, "OVERLAY")
    row.name:SetFont(FONT, 12)
    row.name:SetPoint("TOPLEFT", row.icon, "TOPRIGHT", 8, 1)
    row.name:SetPoint("RIGHT", -300, 0)
    row.name:SetHeight(14)
    row.name:SetJustifyH("LEFT")
    row.name:SetJustifyV("TOP")

    row.source = row:CreateFontString(nil, "OVERLAY")
    row.source:SetFont(FONT, 11, "OUTLINE")
    row.source:SetPoint("BOTTOMLEFT", row.icon, "BOTTOMRIGHT", 8, -2)
    row.source:SetPoint("RIGHT", -300, 0)
    row.source:SetHeight(12)
    row.source:SetJustifyH("LEFT")
    row.source:SetJustifyV("BOTTOM")
    row.source:SetTextColor(0.78, 0.82, 0.9)

    row.value = row:CreateFontString(nil, "OVERLAY")
    row.value:SetFont(FONT, 10)
    row.value:SetPoint("RIGHT", -226, 0)
    row.value:SetWidth(68)
    row.value:SetJustifyH("RIGHT")

    row.cost = row:CreateFontString(nil, "OVERLAY")
    row.cost:SetFont(FONT, 10)
    row.cost:SetPoint("RIGHT", -152, 0)
    row.cost:SetWidth(68)
    row.cost:SetJustifyH("RIGHT")

    row.profit = row:CreateFontString(nil, "OVERLAY")
    row.profit:SetFont(FONT, 10)
    row.profit:SetPoint("RIGHT", -78, 0)
    row.profit:SetWidth(68)
    row.profit:SetJustifyH("RIGHT")

    row.addBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
    row.addBtn:SetSize(62, 20)
    row.addBtn:SetPoint("RIGHT", -8, 0)
    row.addBtn:SetText(T("RL_ADD", "Add"))
    row.addBtn:SetScript("OnClick", function(self)
        local data = self:GetParent().data
        if not data then
            return
        end
        M.state.scanSelectedRecipeKey = data.selectionKey
        M:AddScannedRecipeToQueue()
    end)

    row:SetScript("OnClick", function(self)
        if not self.data then
            return
        end
        M.state.scanSelectedRecipeKey = self.data.selectionKey
        M:RefreshWindow()
    end)

    row:SetScript("OnEnter", function(self)
        if not self.data then
            return
        end

        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(self.data.recipeName or "?", 1, 1, 1)
        GameTooltip:AddLine(self.data.professionName or "?", 0.75, 0.9, 1)
        GameTooltip:AddLine(string.format(
            T("RL_SCAN_TOOLTIP_EPOCH", "Epoch: %s"),
            self.data.epochName or T("RL_FILTER_UNKNOWN", "Unknown")
        ), 0.9, 0.9, 0.9, true)
        GameTooltip:AddLine(string.format(
            T("RL_SCAN_TOOLTIP_CATEGORY", "Category: %s"),
            self.data.categoryName or T("RL_FILTER_UNCATEGORIZED", "Uncategorized")
        ), 0.9, 0.9, 0.9, true)
        GameTooltip:AddLine(string.format(
            T("RL_SCAN_TOOLTIP_CHAR", "Character: %s"),
            FormatCharacterLabel(self.data.charKey)
        ), 0.9, 0.9, 0.9, true)
        GameTooltip:AddLine(string.format(
            T("RL_SCAN_TOOLTIP_TIME", "Scanned: %s"),
            FormatDateTime(self.data.scannedAt)
        ), 0.9, 0.9, 0.9, true)
        GameTooltip:AddLine(string.format(
            T("RL_SCAN_TOOLTIP_REAGENTS", "Saved reagents: %d"),
            #(self.data.reagents or {})
        ), 0.9, 0.9, 0.9, true)

        if self.data._auctionatorRecipeValue then
            local resultName = (self.data._auctionatorResultItemID and GetItemName(self.data._auctionatorResultItemID)) or T("RL_PRICE_UNKNOWN", "—")
            local resultCount = math.max(1, tonumber(self.data._auctionatorResultCount) or 1)
            GameTooltip:AddLine(string.format(
                T("RL_SCAN_TOOLTIP_RESULT_ITEM", "Result: %s x%d"),
                resultName,
                resultCount
            ), 0.75, 0.9, 1, true)
            GameTooltip:AddLine(string.format(
                T("RL_SCAN_TOOLTIP_RESULT_VALUE", "Estimated result value: %s"),
                FormatPriceText(self.data._auctionatorRecipeValue, true)
            ), 0.75, 0.9, 1, true)
        else
            GameTooltip:AddLine(T("RL_SCAN_TOOLTIP_RESULT_UNKNOWN", "Estimated result value: no Auctionator price data"), 1, 0.82, 0.2, true)
        end

        if self.data._auctionatorRecipeCost then
            GameTooltip:AddLine(string.format(
                T("RL_SCAN_TOOLTIP_COST_VALUE", "Estimated reagent cost: %s"),
                FormatPriceText(self.data._auctionatorRecipeCost, self.data._auctionatorRecipeCostComplete)
            ), self.data._auctionatorRecipeCostComplete and 0.75 or 1, self.data._auctionatorRecipeCostComplete and 0.9 or 0.82, 1, true)

            GameTooltip:AddLine(string.format(
                T("RL_SCAN_TOOLTIP_COST_COVERAGE", "Priced reagents: %d/%d"),
                tonumber(self.data._auctionatorPricedReagents) or 0,
                tonumber(self.data._auctionatorTotalReagents) or 0
            ), 0.9, 0.9, 0.9, true)
        elseif IsAuctionatorPriceAPIAvailable and IsAuctionatorPriceAPIAvailable() then
            GameTooltip:AddLine(T("RL_SCAN_TOOLTIP_COST_UNKNOWN", "Estimated reagent cost: no Auctionator price data"), 1, 0.82, 0.2, true)
        else
            GameTooltip:AddLine(T("RL_SCAN_TOOLTIP_COST_NO_AUCTIONATOR", "Estimated reagent cost: Auctionator not found"), 1, 0.4, 0.4, true)
        end

        if self.data._auctionatorRecipeProfit ~= nil then
            local profitText = FormatPriceText(self.data._auctionatorRecipeProfit, self.data._auctionatorRecipeCostComplete, true)
            if self.data._auctionatorRecipeProfit >= 0 then
                GameTooltip:AddLine(string.format(
                    T("RL_SCAN_TOOLTIP_PROFIT", "Estimated profit: %s"),
                    profitText
                ), 0.2, 1, 0.2, true)
            else
                GameTooltip:AddLine(string.format(
                    T("RL_SCAN_TOOLTIP_PROFIT", "Estimated profit: %s"),
                    profitText
                ), 1, 0.25, 0.25, true)
            end
        end

        if self.data._auctionatorRecipeCostComplete == false then
            GameTooltip:AddLine(T("RL_SCAN_TOOLTIP_COST_PARTIAL", "* Partial cost: not all reagents have Auctionator prices."), 1, 0.82, 0.2, true)
        end

        GameTooltip:Show()
    end)

    row:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    return row
end

local function RefreshScanTab(self, f)
    local comboRows = GetScannedCharacterProfessionRows()

    local selectedChar = self.state.scanSelectedCharKey
    local selectedProfessionKey = self.state.scanSelectedProfessionKey
    local selectedCombo = nil

    for _, row in ipairs(comboRows) do
        if row.charKey == selectedChar and row.professionKey == selectedProfessionKey then
            selectedCombo = row
            break
        end
    end

    if not selectedCombo then
        selectedCombo = comboRows[1]
        self.state.scanSelectedCharKey = selectedCombo and selectedCombo.charKey or nil
        self.state.scanSelectedProfessionKey = selectedCombo and selectedCombo.professionKey or nil
        self.state.scanEpochFilter = "all"
        self.state.scanCategoryFilter = "all"
        self.state.scanSelectedRecipeKey = nil
    end

    local allRecipes = selectedCombo and GetScannedRecipesForProfessionGroup(selectedCombo.charKey, selectedCombo.professionKey) or {}
    local epochOptions = BuildFilterOptions(allRecipes, "epochSkillLineID", "epochName")
    if not FindFilterOption(epochOptions, self.state.scanEpochFilter or "all") then
        self.state.scanEpochFilter = "all"
    end

    local epochFilterValue = tostring(self.state.scanEpochFilter or "all")
    local epochFiltered = {}
    for _, row in ipairs(allRecipes) do
        if epochFilterValue == "all" or tostring(row.epochSkillLineID) == epochFilterValue then
            table.insert(epochFiltered, row)
        end
    end

    local mergeCategoriesByName = (epochFilterValue == "all")

    local categoryOptions
    if mergeCategoriesByName then
        categoryOptions = BuildMergedCategoryOptions(epochFiltered)
    else
        categoryOptions = BuildFilterOptions(epochFiltered, "categoryID", "categoryName")
    end

    if not FindFilterOption(categoryOptions, self.state.scanCategoryFilter or "all") then
        self.state.scanCategoryFilter = "all"
    end

    local recipes = {}
    local categoryFilterValue = tostring(self.state.scanCategoryFilter or "all")

    for _, row in ipairs(epochFiltered) do
        local pass = false

        if categoryFilterValue == "all" then
            pass = true
        elseif mergeCategoriesByName then
            pass = (GetMergedCategoryFilterValue(row) == categoryFilterValue)
        else
            pass = (tostring(row.categoryID) == categoryFilterValue)
        end

        if pass then
            table.insert(recipes, row)
        end
    end

    local auctionatorReady = IsAuctionatorPriceAPIAvailable and IsAuctionatorPriceAPIAvailable() or false
    if auctionatorReady and GetRecipeAuctionatorCost then
        for _, row in ipairs(recipes) do
            local cost, complete, pricedCount, totalCount = GetRecipeAuctionatorCost(row)
            local value, resultItemID, resultCount = nil, nil, nil
            local profit = nil

            if GetRecipeAuctionatorValue then
                value, resultItemID, resultCount = GetRecipeAuctionatorValue(row)
            end

            if GetRecipeAuctionatorProfit then
                profit = GetRecipeAuctionatorProfit(row)
            elseif cost and value then
                profit = value - cost
            end

            row._auctionatorRecipeCost = cost
            row._auctionatorRecipeCostComplete = complete
            row._auctionatorPricedReagents = pricedCount
            row._auctionatorTotalReagents = totalCount
            row._auctionatorRecipeValue = value
            row._auctionatorRecipeProfit = profit
            row._auctionatorResultItemID = resultItemID
            row._auctionatorResultCount = resultCount
        end
    else
        for _, row in ipairs(recipes) do
            row._auctionatorRecipeCost = nil
            row._auctionatorRecipeCostComplete = false
            row._auctionatorPricedReagents = 0
            row._auctionatorTotalReagents = #(row.reagents or {})
            row._auctionatorRecipeValue = nil
            row._auctionatorRecipeProfit = nil
            row._auctionatorResultItemID = nil
            row._auctionatorResultCount = nil
        end
    end

    SortScanRecipes(
        recipes,
        self.state.scanRecipeSortKey or "default",
        self.state.scanRecipeSortAsc == true
    )

    local selectedRecipe = nil
    for _, data in ipairs(recipes) do
        if data.selectionKey == self.state.scanSelectedRecipeKey then
            selectedRecipe = data
            break
        end
    end
    if not selectedRecipe then
        self.state.scanSelectedRecipeKey = nil
    end

    f.scanCharContent:SetWidth(math.max(1, (f.scanCharScroll:GetWidth() or 320) - 12))
    f.scanRecipeContent:SetWidth(math.max(DETAILS_MIN_W, math.floor(f.scanRecipeHeader:GetWidth() or DETAILS_MIN_W)))

    EnsureRows(f, "scanCharRows", CreateScanCharacterRow, #comboRows, f.scanCharContent)
    for index, data in ipairs(comboRows) do
        local row = f.scanCharRows[index]
        row:Show()
        row.data = data
        row.charKey = data.charKey
        row.lastSnapshot = data.lastSnapshot
        row.icon:SetTexture(data.icon)
        row.name:SetText(data.displayName or "?")
        row.meta:SetText(tostring(data.recipeCount or 0))

        if data.charKey == GetCharacterKey() then
            row.name:SetTextColor(0.45, 0.85, 1)
        else
            row.name:SetTextColor(1, 1, 1)
        end

        if selectedCombo and data.key == selectedCombo.key then
            row:SetBackdropColor(0.35, 0.55, 1, 0.25)
        else
            row:SetBackdropColor(1, 1, 1, index % 2 == 0 and 0.04 or 0.07)
        end
    end

    for index = #comboRows + 1, #f.scanCharRows do
        f.scanCharRows[index]:Hide()
        f.scanCharRows[index].data = nil
        f.scanCharRows[index].charKey = nil
        f.scanCharRows[index].lastSnapshot = nil
    end
    f.scanCharContent:SetHeight(math.max(1, #comboRows * 34))

    EnsureRows(f, "scanRecipeRows", CreateScanRecipeRow, #recipes, f.scanRecipeContent)
    for index, data in ipairs(recipes) do
        local row = f.scanRecipeRows[index]
        row:Show()
        row.data = data
        row.icon:SetTexture(data.icon)
        row.name:SetText(data.recipeName or "?")
        row.source:SetText(string.format("%s · %s", data.epochName or T("RL_FILTER_UNKNOWN", "Unknown"), data.categoryName or T("RL_FILTER_UNCATEGORIZED", "Uncategorized")))

        row.value:SetText(FormatPriceText(data._auctionatorRecipeValue, true))
        row.cost:SetText(FormatPriceText(data._auctionatorRecipeCost, data._auctionatorRecipeCostComplete))
        row.profit:SetText(FormatPriceText(data._auctionatorRecipeProfit, data._auctionatorRecipeCostComplete, true))

        if data._auctionatorRecipeValue then
            row.value:SetTextColor(0.85, 0.95, 1)
        else
            row.value:SetTextColor(0.55, 0.55, 0.55)
        end

        if data._auctionatorRecipeCost then
            if data._auctionatorRecipeCostComplete then
                row.cost:SetTextColor(0.85, 0.95, 1)
            else
                row.cost:SetTextColor(1, 0.82, 0.2)
            end
        else
            row.cost:SetTextColor(0.55, 0.55, 0.55)
        end

        if data._auctionatorRecipeProfit ~= nil then
            if data._auctionatorRecipeProfit > 0 then
                row.profit:SetTextColor(0.2, 1, 0.2)
            elseif data._auctionatorRecipeProfit < 0 then
                row.profit:SetTextColor(1, 0.25, 0.25)
            else
                row.profit:SetTextColor(0.85, 0.95, 1)
            end
        else
            row.profit:SetTextColor(0.55, 0.55, 0.55)
        end

        if data.selectionKey == self.state.scanSelectedRecipeKey then
            row:SetBackdropColor(0.35, 0.55, 1, 0.25)
        else
            row:SetBackdropColor(1, 1, 1, index % 2 == 0 and 0.04 or 0.07)
        end
    end

    for index = #recipes + 1, #f.scanRecipeRows do
        f.scanRecipeRows[index]:Hide()
        f.scanRecipeRows[index].data = nil
        if f.scanRecipeRows[index].value then
            f.scanRecipeRows[index].value:SetText("")
        end
        if f.scanRecipeRows[index].cost then
            f.scanRecipeRows[index].cost:SetText("")
        end
        if f.scanRecipeRows[index].profit then
            f.scanRecipeRows[index].profit:SetText("")
        end
    end
    f.scanRecipeContent:SetHeight(math.max(1, #recipes * 36))

    f.scanEmptyChars:SetShown(#comboRows == 0)
    f.scanEmptyRecipes:SetShown(#recipes == 0)

    local _, epochOption = FindFilterOption(epochOptions, self.state.scanEpochFilter or "all")
    local _, categoryOption = FindFilterOption(categoryOptions, self.state.scanCategoryFilter or "all")
    f.scanEpochBtn.options = epochOptions
    f.scanCategoryBtn.options = categoryOptions
    f.scanEpochBtn:SetText(string.format("%s: %s", T("RL_FILTER_EPOCH", "Epoch"), (epochOption and epochOption.label) or T("RL_FILTER_ALL", "All")))
    f.scanCategoryBtn:SetText(string.format("%s: %s", T("RL_FILTER_CATEGORY", "Category"), (categoryOption and categoryOption.label) or T("RL_FILTER_ALL", "All")))
    f.scanEpochBtn:SetEnabled(#epochOptions > 0)
    f.scanCategoryBtn:SetEnabled(#categoryOptions > 0)

    if f.scanRecipeValueBtn then
        f.scanRecipeValueBtn:SetEnabled(auctionatorReady)
        UpdateScanSortButtonVisual(
            f.scanRecipeValueBtn,
            T("RL_VALUE", "Value"),
            self.state.scanRecipeSortKey or "default",
            "value",
            self.state.scanRecipeSortAsc == true,
            auctionatorReady
        )
    end

    if f.scanRecipePriceBtn then
        f.scanRecipePriceBtn:SetEnabled(auctionatorReady)
        UpdateScanSortButtonVisual(
            f.scanRecipePriceBtn,
            T("RL_PRICE", "Cost"),
            self.state.scanRecipeSortKey or "default",
            "price",
            self.state.scanRecipeSortAsc == true,
            auctionatorReady
        )
    end

    if f.scanRecipeProfitBtn then
        f.scanRecipeProfitBtn:SetEnabled(auctionatorReady)
        UpdateScanSortButtonVisual(
            f.scanRecipeProfitBtn,
            T("RL_PROFIT", "Profit"),
            self.state.scanRecipeSortKey or "default",
            "profit",
            self.state.scanRecipeSortAsc == true,
            auctionatorReady
        )
    end

    PrimeCurrentProfessionContextFromUI()
    local currentContext, currentReason = GetCurrentProfessionSnapshotContext()
    local canScanCurrent = currentContext ~= nil
        or currentReason == "not_ready"
        or currentReason == "no_recipes"
        or (ProfessionsFrame and ProfessionsFrame:IsShown())
    f.scanBtn:SetEnabled(canScanCurrent)
    f.scanAddBtn:SetEnabled(self.state.scanSelectedRecipeKey ~= nil)

    local selectedName = selectedCombo and selectedCombo.displayName or T("RL_FILTER_ALL", "All")
    local profCount = selectedCombo and 1 or 0
    local recipeCount = selectedCombo and (selectedCombo.recipeCount or 0) or 0
    local lastSnapshot = selectedCombo and (selectedCombo.lastSnapshot or 0) or 0

    if recipeCount > 0 then
        f.scanInfo:SetText(string.format(
            T("RL_SCAN_INFO_READY", "Selected: %s  |  Professions: %d  |  Recipes: %d  |  Last snapshot: %s"),
            selectedName,
            profCount,
            recipeCount,
            lastSnapshot > 0 and FormatDateTime(lastSnapshot) or T("RL_SCAN_NOT_SCANNED", "No snapshot")
        ))
    else
        f.scanInfo:SetText(string.format(
            T("RL_SCAN_INFO_EMPTY", "Selected: %s  |  No saved snapshots yet"),
            selectedName
        ))
    end

    f.summary:SetText(string.format(
        T("RL_SCAN_SUMMARY_FILTERED_AH", "Characters: %d  |  Profession: %s  |  Visible recipes: %d  |  Auctionator: %s  |  Alt + wheel — scale"),
        #comboRows,
        selectedCombo and (selectedCombo.professionName or T("RL_FILTER_ALL", "All")) or T("RL_FILTER_ALL", "All"),
        #recipes,
        auctionatorReady and T("RL_READY", "ready") or T("RL_NOT_READY", "not ready")
    ))
end

function M:GetActiveTab()
    local db = GetDB()
    local tab = tonumber(self.state and self.state.activeTab) or tonumber(db.ui.activeTab) or 2
    if tab ~= 1 then
        tab = 2
    end
    return tab
end

function M:SetActiveTab(tab)
    local db = GetDB()
    tab = tonumber(tab) or 2
    if tab ~= 1 then
        tab = 2
    end

    db.ui.activeTab = tab
    self.state = self.state or {}
    self.state.activeTab = tab

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

function M:ToggleScanRecipeSort(key)
    self.state = self.state or {}
    key = tostring(key or "price")

    if self.state.scanRecipeSortKey == key then
        self.state.scanRecipeSortAsc = not (self.state.scanRecipeSortAsc == true)
    else
        self.state.scanRecipeSortKey = key
        self.state.scanRecipeSortAsc = false
    end

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

local function ClearQueueTabPulseLater(token, duration)
    C_Timer.After(duration, function()
        if not PT or not PT.RecipeList then
            return
        end

        local mod = PT.RecipeList
        if mod._queueTabPulseToken ~= token then
            return
        end

        local f = mod.frame
        if f and f.tabQueue then
            f.tabQueue._rlPulseUntil = nil
        end

        if f and f:IsShown() then
            mod:RefreshWindow()
        end
    end)
end

function M:PulseQueueTab(duration)
    local f = self.frame
    if not f or not f.tabQueue then
        return
    end

    duration = tonumber(duration) or 0.8

    self._queueTabPulseToken = (tonumber(self._queueTabPulseToken) or 0) + 1
    local token = self._queueTabPulseToken

    f.tabQueue._rlPulseUntil = GetTime() + duration
    self:RefreshWindow()

    ClearQueueTabPulseLater(token, duration)
end

function M:SetScale(delta)
    local db = GetDB()
    local cur = db.ui.scale or DEFAULT_SCALE
    local nextScale = cur + delta
    nextScale = math.max(SCALE_MIN, math.min(SCALE_MAX, nextScale))

    db.ui.scale = nextScale

    if self.frame then
        self.frame:SetScale(nextScale)
    end
end

function M:ResetScale()
    local db = GetDB()
    db.ui.scale = DEFAULT_SCALE

    if self.frame then
        self.frame:SetScale(DEFAULT_SCALE)
    end
end

function M:GetQueue()
    local db = GetDB()
    return db.queue
end

function M:ShowWindow()
    local f = self:CreateWindow()
    f:Show()
    self:RefreshWindow()
    self:RefreshTrackers()
    return f
end

function M:ToggleTrackerWindow()
    local f = self:EnsureTrackerWindow()
    if not f then
        return
    end

    if f:IsShown() then
        f:Hide()
    else
        self:RefreshTrackers(true)
    end
end

function M:EnsureTrackerWindow()
    if self.trackerWindow then
        return self.trackerWindow
    end

    local f = CreateFrame("Frame", "AhUIRecipeListTrackerFrame", UIParent, "BackdropTemplate")
    f:SetFrameStrata("DIALOG")
    f:SetClampedToScreen(true)
    f:EnableMouse(true)
    f:EnableMouseWheel(true)
    f:SetResizable(true)
    f:SetResizeBounds(280, 100, 280, 800)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")
    f:SetUserPlaced(true)
    f:Hide()

    RestoreTrackerWindowRect(f)
    SetOwnerFrameBackdrop(f)
    table.insert(UISpecialFrames, f:GetName())

    f:HookScript("OnShow", function()
        if M.UpdateRuntimeEvents then
            M:UpdateRuntimeEvents()
        end
    end)

    f:HookScript("OnHide", function()
        if M.UpdateRuntimeEvents then
            M:UpdateRuntimeEvents()
        end
    end)

    f:SetScript("OnDragStart", function(self)
        if not InCombatLockdown() then
            self:StartMoving()
        end
    end)
    f:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        SaveTrackerWindowRect(self)
    end)
    f:SetScript("OnMouseWheel", function(self, delta)
        if not IsAltKeyDown() then return end
        local db = GetDB()
        db.trackerUI = db.trackerUI or {}
        local cur = db.trackerUI.scale or DEFAULT_SCALE
        cur = math.max(SCALE_MIN, math.min(SCALE_MAX, cur + (delta > 0 and SCALE_STEP or -SCALE_STEP)))
        db.trackerUI.scale = cur
        self:SetScale(cur)
    end)
    f:SetScript("OnMouseUp", function(self, button)
        if button == "MiddleButton" and IsAltKeyDown() then
            local db = GetDB()
            db.trackerUI = db.trackerUI or {}
            db.trackerUI.scale = DEFAULT_SCALE
            self:SetScale(DEFAULT_SCALE)
        end
    end)

    f.title = f:CreateFontString(nil, "OVERLAY")
    f.title:SetFont(FONT, 12, "OUTLINE")
    f.title:SetPoint("TOPLEFT", 10, -10)
    f.title:SetText(T("RL_TRACKERS_TITLE", "Tracked reagents"))

    f.close = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    f.close:SetPoint("TOPRIGHT", -4, -4)

    f.header = CreateFrame("Frame", nil, f, "BackdropTemplate")
    f.header:SetPoint("TOPLEFT", 10, -30)
    f.header:SetPoint("TOPRIGHT", -10, -30)
    f.header:SetHeight(20)
    f.header:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
    f.header:SetBackdropColor(1, 1, 1, 0.05)

    local h1 = f.header:CreateFontString(nil, "OVERLAY")
    h1:SetFont(FONT, 10)
    h1:SetPoint("LEFT", 6, 0)
    h1:SetText(T("RL_COL_REAGENT", "Reagent"))

    local h2 = f.header:CreateFontString(nil, "OVERLAY")
    h2:SetFont(FONT, 10)
    h2:SetPoint("RIGHT", -28, 0)
    h2:SetText(T("RL_COL_HAVE", "In bags"))

    f.scroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    f.scroll:SetPoint("TOPLEFT", f.header, "BOTTOMLEFT", 0, -4)
    f.scroll:SetPoint("BOTTOMRIGHT", -6, 10)

    f.content = CreateFrame("Frame", nil, f.scroll)
    f.content:SetSize(1, 1)
    f.scroll:SetScrollChild(f.content)
    HideScrollBar(f.scroll)
    f.rows = {}

    f.empty = f:CreateFontString(nil, "OVERLAY")
    f.empty:SetFont(FONT, 10)
    f.empty:SetPoint("CENTER")
    f.empty:SetText(T("RL_TRACKER_EMPTY", "No tracked reagents"))

    local resizeBtn = CreateFrame("Frame", nil, f)
    resizeBtn:SetSize(16, 16)
    resizeBtn:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -4, 4)
    resizeBtn:EnableMouse(true)

    local resizeTex = resizeBtn:CreateTexture(nil, "OVERLAY")
    resizeTex:SetPoint("TOPLEFT", resizeBtn, "TOPLEFT", 2, -2)
    resizeTex:SetPoint("BOTTOMRIGHT", resizeBtn, "BOTTOMRIGHT", -2, 2)
    resizeTex:SetTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
    resizeTex:SetAlpha(0.85)

    resizeBtn:SetScript("OnMouseDown", function(_, button)
        if button ~= "LeftButton" or InCombatLockdown() then return end
        f:StartSizing("BOTTOMRIGHT")
        resizeTex:SetAlpha(1)
    end)
    resizeBtn:SetScript("OnMouseUp", function(_, button)
        if button ~= "LeftButton" then return end
        f:StopMovingOrSizing()
        SaveTrackerWindowRect(f)
        resizeTex:SetAlpha(0.85)
        M:RefreshTrackers()
    end)

    self.trackerWindow = f
    return f
end

local function CreateTrackerRow(parent, index)
    local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
    row:SetHeight(32)
    row:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
    row:SetBackdropColor(1, 1, 1, index % 2 == 0 and 0.04 or 0.07)

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(18, 18)
    row.icon:SetPoint("LEFT", 6, 0)

    row.count = row:CreateFontString(nil, "OVERLAY")
    row.count:SetFont(FONT, 10, "OUTLINE")
    row.count:SetPoint("RIGHT", -28, 0)
    row.count:SetWidth(66)
    row.count:SetJustifyH("RIGHT")

    row.name = row:CreateFontString(nil, "OVERLAY")
    row.name:SetFont(FONT, 10)
    row.name:SetPoint("LEFT", row.icon, "RIGHT", 8, 0)
    row.name:SetPoint("RIGHT", row.count, "LEFT", -8, 0)
    row.name:SetJustifyH("LEFT")

    row.remove = CreateFrame("Button", nil, row, "UIPanelCloseButton")
    row.remove:SetSize(18, 18)
    row.remove:SetPoint("RIGHT", -4, 0)
    row.remove:SetScript("OnClick", function(self)
        local key = self:GetParent().trackKey
        for index, tracker in ipairs(GetDB().trackers or {}) do
            if tracker.key == key then
                table.remove(GetDB().trackers, index)
                break
            end
        end
        M:RefreshWindow()
        M:RefreshTrackers()
    end)

    return row
end

function M:RefreshTrackers(forceShow)
    local db = GetDB()
    local trackers = db.trackers or {}

    local f = self.trackerWindow

    if not f and not forceShow then
        return
    end

    if not f then
        f = self:EnsureTrackerWindow()
    end

    if not f then
        return
    end

    if not f:IsShown() and not forceShow then
        return
    end

    local rowsData = {}
    local cleaned = {}

    if BuildTrackedDisplayRows then
        rowsData, cleaned = BuildTrackedDisplayRows(trackers, GetDB().queue)
    else
        local displayRows = BuildDisplayRows(GetDB().queue)
        local displayRowsByKey = {}

        for _, rowData in ipairs(displayRows) do
            if rowData and rowData.trackKey then
                displayRowsByKey[rowData.trackKey] = rowData
            end
        end

        for _, tracker in ipairs(trackers) do
            local rowData = displayRowsByKey[tracker.key]
            if rowData then
                table.insert(rowsData, rowData)
                table.insert(cleaned, tracker)
            end
        end
    end

    db.trackers = cleaned

    if forceShow and not f:IsShown() then
        f:Show()
    end

    f.content:SetWidth(math.max(1, f.scroll:GetWidth() - 8))
    EnsureRows(f, "rows", CreateTrackerRow, #rowsData, f.content)

    for index, data in ipairs(rowsData) do
        local row = f.rows[index]
        row:Show()
        row.trackKey = data.trackKey
        row.icon:SetTexture(data.icon)
        row.name:SetText(data.name or "?")
        row.count:SetText(string.format("%d / %d", data.need or 0, data.have or 0))

        if data.have >= data.need and data.need > 0 then
            row.count:SetTextColor(0.2, 1, 0.2)
        elseif data.need <= 0 then
            row.count:SetTextColor(0.75, 0.75, 0.75)
        else
            row.count:SetTextColor(1, 0.25, 0.25)
        end
    end

    for index = #rowsData + 1, #f.rows do
        f.rows[index]:Hide()
        f.rows[index].trackKey = nil
    end

    f.content:SetHeight(math.max(1, #rowsData * 34))
    f.empty:SetShown(#rowsData == 0)
end
function M:CreateWindow()
    if self.frame then
        return self.frame
    end

    local f = CreateFrame("Frame", "AhUIRecipeListFrame", UIParent, "BackdropTemplate")
    f:SetFrameStrata("DIALOG")
    f:SetClampedToScreen(true)
    f:EnableMouse(true)
    f:EnableMouseWheel(true)
    f:SetResizable(true)
    f:SetResizeBounds(MIN_W, MIN_H, MAX_W, MAX_H)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")
    f:SetUserPlaced(true)
    f:Hide()

    RestoreWindowRect(f)
    SetOwnerFrameBackdrop(f)

    table.insert(UISpecialFrames, f:GetName())

    f:HookScript("OnShow", function()
        if M.UpdateRuntimeEvents then
            M:UpdateRuntimeEvents()
        end
    end)

    f:HookScript("OnHide", function()
        if M.UpdateRuntimeEvents then
            M:UpdateRuntimeEvents()
        end
    end)

    f:SetScript("OnDragStart", function(self)
        if not InCombatLockdown() then
            self:StartMoving()
        end
    end)

    f:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        SaveWindowRect(self)
    end)

    f:SetScript("OnMouseWheel", function(self, delta)
        if not IsAltKeyDown() then
            return
        end

        if delta > 0 then
            M:SetScale(SCALE_STEP)
        else
            M:SetScale(-SCALE_STEP)
        end
    end)

    f:SetScript("OnMouseUp", function(self, button)
        if button == "MiddleButton" and IsAltKeyDown() then
            M:ResetScale()
        end
    end)

    f.title = f:CreateFontString(nil, "OVERLAY")
    f.title:SetFont(FONT, 14, "OUTLINE")
    f.title:SetPoint("TOPLEFT", 12, -10)
    f.title:SetText(T("RL_WINDOW_TITLE", "AhUI List"))

    f.close = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    f.close:SetPoint("TOPRIGHT", -4, -4)

    f.tabChars = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.tabChars:SetSize(132, 24)
    f.tabChars:SetPoint("TOPLEFT", 150, -6)
    f.tabChars:SetText(T("RL_TAB_CHARS", "Characters"))
    f.tabChars:SetScript("OnClick", function()
        M:SetActiveTab(1)
    end)

    f.tabQueue = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.tabQueue:SetSize(132, 24)
    f.tabQueue:SetPoint("LEFT", f.tabChars, "RIGHT", 6, 0)
    f.tabQueue:SetText(T("RL_TAB_QUEUE", "Queue"))
    f.tabQueue:SetScript("OnClick", function()
        M:SetActiveTab(2)
    end)

    f.trackersBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.trackersBtn:SetSize(110, 24)
    f.trackersBtn:SetPoint("LEFT", f.tabQueue, "RIGHT", 6, 0)
    f.trackersBtn:SetText(T("RL_TRACKERS_BUTTON", "Trackers"))
    f.trackersBtn:SetScript("OnClick", function()
        M:ToggleTrackerWindow()
    end)

    f.removeBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.removeBtn:SetSize(92, 22)
    f.removeBtn:SetPoint("TOPLEFT", 12, -36)
    f.removeBtn:SetText(T("RL_REMOVE", "Remove"))
    f.removeBtn:SetScript("OnClick", function()
        M:RemoveSelectedRecipe()
    end)

    f.clearBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.clearBtn:SetSize(80, 22)
    f.clearBtn:SetPoint("LEFT", f.removeBtn, "RIGHT", 6, 0)
    f.clearBtn:SetText(T("RL_CLEAR", "Clear"))
    f.clearBtn:SetScript("OnClick", function()
        M:ClearQueue()
    end)

    f.buyBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.buyBtn:SetSize(86, 22)
    f.buyBtn:SetPoint("LEFT", f.clearBtn, "RIGHT", 6, 0)
    f.buyBtn:SetText(T("RL_BUY", "Buy"))
    f.buyBtn:SetScript("OnClick", function()
        M:OpenShopping()
    end)

    f.craftBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.craftBtn:SetSize(86, 22)
    f.craftBtn:SetPoint("LEFT", f.buyBtn, "RIGHT", 18, 0)
    f.craftBtn:SetText(T("RL_CRAFT_ONE", "Craft"))
    f.craftBtn:SetScript("OnClick", function()
        M:CraftSelected(false)
    end)

    f.craftAllBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.craftAllBtn:SetSize(96, 22)
    f.craftAllBtn:SetPoint("LEFT", f.craftBtn, "RIGHT", 6, 0)
    f.craftAllBtn:SetText(T("RL_CRAFT_ALL", "Craft all"))
    f.craftAllBtn:SetScript("OnClick", function()
        M:CraftSelected(true)
    end)

    f.recipeLine = f:CreateFontString(nil, "OVERLAY")
    f.recipeLine:SetFont(FONT, 12)
    f.recipeLine:SetPoint("TOPLEFT", 12, -68)
    f.recipeLine:SetPoint("RIGHT", -270, 0)
    f.recipeLine:SetJustifyH("LEFT")
    f.recipeLine:SetText("")

    f.qtyLabel = f:CreateFontString(nil, "OVERLAY")
    f.qtyLabel:SetFont(FONT, 11)
    f.qtyLabel:SetPoint("TOPRIGHT", -198, -68)
    f.qtyLabel:SetText(T("RL_QUANTITY", "Craft amount:"))

    f.minusBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.minusBtn:SetSize(24, 22)
    f.minusBtn:SetPoint("LEFT", f.qtyLabel, "RIGHT", 8, 0)
    f.minusBtn:SetText("-")
    f.minusBtn:SetScript("OnClick", function()
        M:AdjustSelectedQuantity(-1)
    end)

    f.qtyInput = CreateFrame("EditBox", nil, f, "InputBoxTemplate")
    f.qtyInput:SetSize(54, 22)
    f.qtyInput:SetPoint("LEFT", f.minusBtn, "RIGHT", 6, 0)
    f.qtyInput:SetAutoFocus(false)
    f.qtyInput:SetNumeric(true)
    f.qtyInput:SetMaxLetters(5)
    f.qtyInput:SetText("1")
    f.qtyInput:SetScript("OnEnterPressed", function(self)
        self:ClearFocus()
        M:SetSelectedQuantity(tonumber(self:GetText()) or 1)
    end)
    f.qtyInput:SetScript("OnEditFocusLost", function(self)
        M:SetSelectedQuantity(tonumber(self:GetText()) or 1)
    end)

    f.plusBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.plusBtn:SetSize(24, 22)
    f.plusBtn:SetPoint("LEFT", f.qtyInput, "RIGHT", 6, 0)
    f.plusBtn:SetText("+")
    f.plusBtn:SetScript("OnClick", function()
        M:AdjustSelectedQuantity(1)
    end)

    f.cheapCheck = CreateFrame("CheckButton", nil, f, "UICheckButtonTemplate")
    f.cheapCheck:SetPoint("TOPLEFT", 12, -92)
    do
        local checkName = f.cheapCheck:GetName()
        f.cheapCheck.text = f.cheapCheck.Text or (checkName and _G[checkName .. "Text"]) or nil
    end
    if f.cheapCheck.text then
        f.cheapCheck.text:SetFont(FONT, 11)
        f.cheapCheck.text:SetText(T("RL_AUTO_REAGENTS", "Cheapest reagents"))
    end
    f.cheapCheck:SetScript("OnClick", function(self)
        M:SetSelectedAutoCheap(self:GetChecked())
    end)

    f.concCheck = CreateFrame("CheckButton", nil, f, "UICheckButtonTemplate")
    f.concCheck:SetPoint("LEFT", f.cheapCheck, "RIGHT", 210, 0)
    do
        local checkName = f.concCheck:GetName()
        f.concCheck.text = f.concCheck.Text or (checkName and _G[checkName .. "Text"]) or nil
    end
    if f.concCheck.text then
        f.concCheck.text:SetFont(FONT, 11)
        f.concCheck.text:SetText(T("RL_USE_CONCENTRATION", "Use concentration"))
    end
    f.concCheck:SetScript("OnClick", function(self)
        M:SetSelectedUseConcentration(self:GetChecked())
    end)
    f.concCheck:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(T("RL_USE_CONCENTRATION", "Use concentration"), 1, 1, 1)
        GameTooltip:AddLine(
            T("RL_USE_CONCENTRATION_TOOLTIP", "If enabled, AhUI will craft only with concentration. If concentration is not available or not enough, crafting will not start."),
            0.9, 0.9, 0.9, true
        )
        GameTooltip:Show()
    end)
    f.concCheck:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    f.queueHeader = CreateFrame("Frame", nil, f, "BackdropTemplate")
    f.queueHeader:SetPoint("TOPLEFT", 12, -124)
    f.queueHeader:SetWidth(QUEUE_W)
    f.queueHeader:SetHeight(22)
    f.queueHeader:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
    f.queueHeader:SetBackdropColor(1, 1, 1, 0.05)

    local qh1 = f.queueHeader:CreateFontString(nil, "OVERLAY")
    qh1:SetFont(FONT, 10)
    qh1:SetPoint("LEFT", 8, 0)
    qh1:SetText(T("RL_QUEUE", "Craft queue"))

    local qh2 = f.queueHeader:CreateFontString(nil, "OVERLAY")
    qh2:SetFont(FONT, 10)
    qh2:SetPoint("RIGHT", -10, 0)
    qh2:SetText(T("RL_QUEUE_QTY", "Qty"))

    f.queueScroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    f.queueScroll:SetPoint("TOPLEFT", f.queueHeader, "BOTTOMLEFT", 0, -4)
    f.queueScroll:SetPoint("BOTTOMLEFT", 12, 36)
    f.queueScroll:SetWidth(QUEUE_W + 4)
    f.queueScroll:EnableMouseWheel(true)

    f.queueContent = CreateFrame("Frame", nil, f.queueScroll)
    f.queueContent:SetSize(QUEUE_W - 8, 1)
    f.queueScroll:SetScrollChild(f.queueContent)
    HideScrollBar(f.queueScroll)
    f.queueRows = {}

    f.detailsHeader = CreateFrame("Frame", nil, f, "BackdropTemplate")
    f.detailsHeader:SetPoint("TOPLEFT", f.queueHeader, "TOPRIGHT", 12, 0)
    f.detailsHeader:SetPoint("TOPRIGHT", -12, -124)
    f.detailsHeader:SetHeight(22)
    f.detailsHeader:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
    f.detailsHeader:SetBackdropColor(1, 1, 1, 0.05)

    local function MakeHeader(text, x, width, justify)
        local fs = f.detailsHeader:CreateFontString(nil, "OVERLAY")
        fs:SetFont(FONT, 10)
        fs:SetPoint("LEFT", x, 0)
        fs:SetWidth(width)
        fs:SetJustifyH(justify or "LEFT")
        fs:SetText(text)
        return fs
    end

    MakeHeader(T("RL_COL_TRACK", "+"), COL_TRACK_X, COL_TRACK_W, "CENTER")
    MakeHeader(T("RL_COL_REAGENT", "Reagent"), COL_REAGENT_X, COL_REAGENT_W, "LEFT")
    MakeHeader(T("RL_COL_GRADE", "Grade"), COL_GRADE_X, COL_GRADE_W, "CENTER")
    MakeHeader(T("RL_COL_NEED", "Need"), COL_NEED_X, COL_NEED_W, "RIGHT")
    MakeHeader(T("RL_COL_HAVE", "Bags"), COL_HAVE_X, COL_HAVE_W, "RIGHT")
    MakeHeader(T("RL_COL_STATE", "State"), COL_STATE_X, COL_STATE_W, "CENTER")

    f.detailsScroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    f.detailsScroll:SetPoint("TOPLEFT", f.detailsHeader, "BOTTOMLEFT", 0, -4)
    f.detailsScroll:SetPoint("BOTTOMRIGHT", -6, 36)
    f.detailsScroll:EnableMouseWheel(true)

    f.detailsContent = CreateFrame("Frame", nil, f.detailsScroll)
    f.detailsContent:SetSize(DETAILS_MIN_W, 1)
    f.detailsScroll:SetScrollChild(f.detailsContent)
    HideScrollBar(f.detailsScroll)
    f.detailRows = {}

    f.empty = f:CreateFontString(nil, "OVERLAY")
    f.empty:SetFont(FONT, 11)
    f.empty:SetPoint("CENTER", 100, -10)
    f.empty:SetText(T("RL_EMPTY", "There are no reagents in the queue"))
    f.empty:Hide()

    f.queueEmpty = f:CreateFontString(nil, "OVERLAY")
    f.queueEmpty:SetFont(FONT, 11)
    f.queueEmpty:SetPoint("CENTER", -200, -10)
    f.queueEmpty:SetText(T("RL_EMPTY_QUEUE", "Queue is empty"))
    f.queueEmpty:Hide()

    f.summary = f:CreateFontString(nil, "OVERLAY")
    f.summary:SetFont(FONT, 10)
    f.summary:SetPoint("BOTTOMLEFT", 12, 12)
    f.summary:SetPoint("BOTTOMRIGHT", -12, 12)
    f.summary:SetJustifyH("LEFT")
    f.summary:SetText(T("RL_SCALE_HINT", "Alt + wheel — scale, Alt + middle click — reset scale"))

    f.scanBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.scanBtn:SetSize(196, 22)
    f.scanBtn:SetPoint("TOPLEFT", 12, -36)
    f.scanBtn:SetText(T("RL_SCAN_CURRENT", "Scan"))
    f.scanBtn:SetScript("OnClick", function()
        M:ScanCurrentProfession()
    end)

    f.scanAddBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.scanAddBtn:SetSize(132, 22)
    f.scanAddBtn:SetPoint("LEFT", f.scanBtn, "RIGHT", 6, 0)
    f.scanAddBtn:SetText(T("RL_ADD_TO_QUEUE", "Add to queue"))
    f.scanAddBtn:SetScript("OnClick", function()
        M:AddScannedRecipeToQueue()
    end)

    f.scanEpochBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.scanEpochBtn:SetSize(180, 22)
    f.scanEpochBtn:SetPoint("LEFT", f.scanAddBtn, "RIGHT", 18, 0)
    f.scanEpochBtn:SetScript("OnClick", function(self)
        local options = self.options or { { value = "all", label = T("RL_FILTER_ALL", "All") } }
        ShowFilterDropdown(self, T("RL_FILTER_EPOCH", "Epoch"), options, M.state.scanEpochFilter or "all", function(value)
            M.state.scanEpochFilter = value or "all"
            M.state.scanCategoryFilter = "all"
            M.state.scanSelectedRecipeKey = nil
            M:RefreshWindow()
        end)
    end)

    f.scanCategoryBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.scanCategoryBtn:SetSize(190, 22)
    f.scanCategoryBtn:SetPoint("LEFT", f.scanEpochBtn, "RIGHT", 6, 0)
    f.scanCategoryBtn:SetScript("OnClick", function(self)
        local options = self.options or { { value = "all", label = T("RL_FILTER_ALL", "All") } }
        ShowFilterDropdown(self, T("RL_FILTER_CATEGORY", "Category"), options, M.state.scanCategoryFilter or "all", function(value)
            M.state.scanCategoryFilter = value or "all"
            M.state.scanSelectedRecipeKey = nil
            M:RefreshWindow()
        end)
    end)

    f.scanInfo = f:CreateFontString(nil, "OVERLAY")
    f.scanInfo:SetFont(FONT, 11)
    f.scanInfo:SetPoint("TOPLEFT", 12, -66)
    f.scanInfo:SetPoint("RIGHT", -12, 0)
    f.scanInfo:SetJustifyH("LEFT")
    f.scanInfo:SetText("")

    f.scanCharHeader = CreateFrame("Frame", nil, f, "BackdropTemplate")
    f.scanCharHeader:SetPoint("TOPLEFT", 12, -92)
    f.scanCharHeader:SetWidth(320)
    f.scanCharHeader:SetHeight(22)
    f.scanCharHeader:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
    f.scanCharHeader:SetBackdropColor(1, 1, 1, 0.05)

    local sch1 = f.scanCharHeader:CreateFontString(nil, "OVERLAY")
    sch1:SetFont(FONT, 10)
    sch1:SetPoint("LEFT", 8, 0)
    sch1:SetText(T("RL_SCAN_CHAR_HEADER", "Characters"))

    local sch2 = f.scanCharHeader:CreateFontString(nil, "OVERLAY")
    sch2:SetFont(FONT, 10)
    sch2:SetPoint("RIGHT", -8, 0)
    sch2:SetText(T("RL_SCAN_PROF_COUNT_SHORT", "Qty"))

    f.scanCharScroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    f.scanCharScroll:SetPoint("TOPLEFT", f.scanCharHeader, "BOTTOMLEFT", 0, -4)
    f.scanCharScroll:SetPoint("BOTTOMLEFT", 12, 36)
    f.scanCharScroll:SetWidth(324)

    f.scanCharContent = CreateFrame("Frame", nil, f.scanCharScroll)
    f.scanCharContent:SetSize(312, 1)
    f.scanCharScroll:SetScrollChild(f.scanCharContent)
    HideScrollBar(f.scanCharScroll)
    f.scanCharRows = {}

    f.scanRecipeHeader = CreateFrame("Frame", nil, f, "BackdropTemplate")
    f.scanRecipeHeader:SetPoint("TOPLEFT", f.scanCharHeader, "TOPRIGHT", 12, 0)
    f.scanRecipeHeader:SetPoint("TOPRIGHT", -12, -92)
    f.scanRecipeHeader:SetHeight(22)
    f.scanRecipeHeader:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
    f.scanRecipeHeader:SetBackdropColor(1, 1, 1, 0.05)

    local srh1 = f.scanRecipeHeader:CreateFontString(nil, "OVERLAY")
    srh1:SetFont(FONT, 10)
    srh1:SetPoint("LEFT", 8, 0)
    srh1:SetText(T("RL_SCAN_RECIPES_HEADER", "Scanned recipes"))

    local srh2 = f.scanRecipeHeader:CreateFontString(nil, "OVERLAY")
    srh2:SetFont(FONT, 10)
    srh2:SetPoint("RIGHT", -16, 0)
    srh2:SetText(T("RL_ADD", "Add"))

    f.scanRecipeValueBtn = CreateFrame("Button", nil, f.scanRecipeHeader)
    f.scanRecipeValueBtn:SetSize(72, 20)
    f.scanRecipeValueBtn:SetPoint("RIGHT", -226, 0)
    f.scanRecipeValueBtn:SetScript("OnClick", function()
        M:ToggleScanRecipeSort("value")
    end)

    f.scanRecipeValueBtn.label = f.scanRecipeValueBtn:CreateFontString(nil, "OVERLAY")
    f.scanRecipeValueBtn.label:SetFont(FONT, 10)
    f.scanRecipeValueBtn.label:SetPoint("CENTER", -8, 0)
    f.scanRecipeValueBtn.label:SetText(T("RL_VALUE", "Value"))

    f.scanRecipeValueBtn.sortIcon = f.scanRecipeValueBtn:CreateTexture(nil, "OVERLAY")
    f.scanRecipeValueBtn.sortIcon:SetSize(14, 14)
    f.scanRecipeValueBtn.sortIcon:SetPoint("LEFT", f.scanRecipeValueBtn.label, "RIGHT", 4, 0)
    f.scanRecipeValueBtn.sortIcon:Hide()

    f.scanRecipeValueBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:SetText(T("RL_VALUE_SORT_TITLE", "Result value"), 1, 1, 1)
        GameTooltip:AddLine(T("RL_VALUE_SORT_TOOLTIP", "Click to sort recipes by estimated Auctionator value of the crafted result."), 0.9, 0.9, 0.9, true)
        GameTooltip:Show()
    end)
    f.scanRecipeValueBtn:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    f.scanRecipePriceBtn = CreateFrame("Button", nil, f.scanRecipeHeader)
    f.scanRecipePriceBtn:SetSize(72, 20)
    f.scanRecipePriceBtn:SetPoint("RIGHT", -152, 0)
    f.scanRecipePriceBtn:SetScript("OnClick", function()
        M:ToggleScanRecipeSort("price")
    end)

    f.scanRecipePriceBtn.label = f.scanRecipePriceBtn:CreateFontString(nil, "OVERLAY")
    f.scanRecipePriceBtn.label:SetFont(FONT, 10)
    f.scanRecipePriceBtn.label:SetPoint("CENTER", -8, 0)
    f.scanRecipePriceBtn.label:SetText(T("RL_PRICE", "Cost"))

    f.scanRecipePriceBtn.sortIcon = f.scanRecipePriceBtn:CreateTexture(nil, "OVERLAY")
    f.scanRecipePriceBtn.sortIcon:SetSize(14, 14)
    f.scanRecipePriceBtn.sortIcon:SetPoint("LEFT", f.scanRecipePriceBtn.label, "RIGHT", 4, 0)
    f.scanRecipePriceBtn.sortIcon:Hide()

    f.scanRecipePriceBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:SetText(T("RL_PRICE_SORT_TITLE", "Recipe cost"), 1, 1, 1)
        GameTooltip:AddLine(T("RL_PRICE_SORT_TOOLTIP", "Click to sort recipes by estimated reagent cost from Auctionator data."), 0.9, 0.9, 0.9, true)
        GameTooltip:Show()
    end)
    f.scanRecipePriceBtn:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    f.scanRecipeProfitBtn = CreateFrame("Button", nil, f.scanRecipeHeader)
    f.scanRecipeProfitBtn:SetSize(72, 20)
    f.scanRecipeProfitBtn:SetPoint("RIGHT", -78, 0)
    f.scanRecipeProfitBtn:SetScript("OnClick", function()
        M:ToggleScanRecipeSort("profit")
    end)

    f.scanRecipeProfitBtn.label = f.scanRecipeProfitBtn:CreateFontString(nil, "OVERLAY")
    f.scanRecipeProfitBtn.label:SetFont(FONT, 10)
    f.scanRecipeProfitBtn.label:SetPoint("CENTER", -8, 0)
    f.scanRecipeProfitBtn.label:SetText(T("RL_PROFIT", "Profit"))

    f.scanRecipeProfitBtn.sortIcon = f.scanRecipeProfitBtn:CreateTexture(nil, "OVERLAY")
    f.scanRecipeProfitBtn.sortIcon:SetSize(14, 14)
    f.scanRecipeProfitBtn.sortIcon:SetPoint("LEFT", f.scanRecipeProfitBtn.label, "RIGHT", 4, 0)
    f.scanRecipeProfitBtn.sortIcon:Hide()

    f.scanRecipeProfitBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:SetText(T("RL_PROFIT_SORT_TITLE", "Recipe profit"), 1, 1, 1)
        GameTooltip:AddLine(T("RL_PROFIT_SORT_TOOLTIP", "Click to sort recipes by estimated profit: result value minus reagent cost."), 0.9, 0.9, 0.9, true)
        GameTooltip:Show()
    end)
    f.scanRecipeProfitBtn:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    f.scanRecipeScroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    f.scanRecipeScroll:SetPoint("TOPLEFT", f.scanRecipeHeader, "BOTTOMLEFT", 0, -4)
    f.scanRecipeScroll:SetPoint("BOTTOMRIGHT", -6, 36)

    f.scanRecipeContent = CreateFrame("Frame", nil, f.scanRecipeScroll)
    f.scanRecipeContent:SetSize(DETAILS_MIN_W, 1)
    f.scanRecipeScroll:SetScrollChild(f.scanRecipeContent)
    HideScrollBar(f.scanRecipeScroll)
    f.scanRecipeRows = {}

    f.scanEmptyChars = f:CreateFontString(nil, "OVERLAY")
    f.scanEmptyChars:SetFont(FONT, 11)
    f.scanEmptyChars:SetPoint("CENTER", f.scanCharScroll, "CENTER", 0, 0)
    f.scanEmptyChars:SetText(T("RL_SCAN_NO_CHARS", "No saved snapshots"))


    f.scanEmptyRecipes = f:CreateFontString(nil, "OVERLAY")
    f.scanEmptyRecipes:SetFont(FONT, 11)
    f.scanEmptyRecipes:SetPoint("CENTER", f.scanRecipeScroll, "CENTER", 0, 0)
    f.scanEmptyRecipes:SetText(T("RL_SCAN_NO_RECIPES", "No scanned recipes for this filter"))

    f.queueTabWidgets = {
        f.removeBtn, f.clearBtn, f.buyBtn, f.craftBtn, f.craftAllBtn,
        f.recipeLine, f.qtyLabel, f.minusBtn, f.qtyInput, f.plusBtn, f.cheapCheck, f.concCheck,
        f.queueHeader, f.queueScroll, f.detailsHeader, f.detailsScroll,
        f.empty, f.queueEmpty,
    }

    f.scanTabWidgets = {
        f.scanBtn, f.scanAddBtn, f.scanEpochBtn, f.scanCategoryBtn, f.scanInfo,
        f.scanRecipeValueBtn, f.scanRecipePriceBtn, f.scanRecipeProfitBtn,
        f.scanCharHeader, f.scanCharScroll,
        f.scanRecipeHeader, f.scanRecipeScroll,
        f.scanEmptyChars, f.scanEmptyRecipes,
    }

    local resizeBtn = CreateFrame("Frame", nil, f)
    resizeBtn:SetSize(16, 16)
    resizeBtn:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -4, 4)
    resizeBtn:SetFrameStrata(f:GetFrameStrata())
    resizeBtn:SetFrameLevel(f:GetFrameLevel() + 50)
    resizeBtn:EnableMouse(true)

    local resizeTex = resizeBtn:CreateTexture(nil, "OVERLAY")
    resizeTex:SetPoint("TOPLEFT", resizeBtn, "TOPLEFT", 2, -2)
    resizeTex:SetPoint("BOTTOMRIGHT", resizeBtn, "BOTTOMRIGHT", -2, 2)
    resizeTex:SetTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
    resizeTex:SetAlpha(0.85)

    resizeBtn:SetScript("OnMouseDown", function(_, button)
        if button ~= "LeftButton" then return end
        if InCombatLockdown() then return end

        f:StartSizing("BOTTOMRIGHT")
        resizeTex:SetAlpha(1)
    end)

    resizeBtn:SetScript("OnMouseUp", function(_, button)
        if button ~= "LeftButton" then return end

        f:StopMovingOrSizing()
        SaveWindowRect(f)
        resizeTex:SetAlpha(0.85)
        M:RefreshWindow()
    end)

    self.frame = f
    return f
end

function M:RefreshWindow()
    local f = self.frame
    if not f or not f:IsShown() then
        return
    end

    local activeTab = self:GetActiveTab()
    ApplyMainTabVisibility(f, activeTab)

    if activeTab == 1 then
        RefreshScanTab(self, f)
        return
    end

    local db = GetDB()
    local queue = db.queue or {}
    local queueRowsData = BuildQueueListRows(queue)
    local selectedEntry = GetSelectedEntry()
    local visibleQueue = GetVisibleQueueForDetails(queue)
    local detailRows = BuildDisplayRows(visibleQueue)
    local visibleMissingMaterials = BuildMissingMaterialsForQueue(visibleQueue)
    local totalMissingMaterials = BuildMissingMaterialsForQueue(queue)
    local queueSummary = BuildQueueSummary(queue)

    f.queueEmpty:SetShown(#queue == 0)
    f.empty:SetShown(#queue > 0 and #detailRows == 0)

    EnsureRows(f, "queueRows", CreateQueueRow, #queueRowsData, f.queueContent)
    for index, rowData in ipairs(queueRowsData) do
        local row = f.queueRows[index]
        row:Show()
        row.queueIndex = rowData.queueIndex
        row.entry = rowData.entry
        row.isAll = rowData.isAll
        row.queueSummary = rowData.queueSummary
        row.icon:SetTexture(rowData.icon)
        row.name:SetText(rowData.recipeName or "?")
        row.source:SetText((not rowData.isAll and rowData.sourceCharLabel) or "")
        row.qty:SetText(rowData.qtyText or "")

        if rowData.queueIndex == (tonumber(self.state.selectedIndex) or 0) then
            row:SetBackdropColor(0.35, 0.55, 1, 0.25)
        else
            row:SetBackdropColor(1, 1, 1, index % 2 == 0 and 0.04 or 0.07)
        end
    end
    for index = #queueRowsData + 1, #f.queueRows do
        f.queueRows[index]:Hide()
        f.queueRows[index].entry = nil
        f.queueRows[index].queueIndex = nil
        f.queueRows[index].isAll = nil
        f.queueRows[index].queueSummary = nil
    end
    f.queueContent:SetHeight(math.max(1, #queueRowsData * (QUEUE_ROW_H + ROW_GAP)))

    local detailsWidth = math.max(DETAILS_MIN_W, math.floor(f.detailsHeader:GetWidth() or DETAILS_MIN_W))
    f.detailsContent:SetWidth(detailsWidth)

    EnsureRows(f, "detailRows", CreateReagentRow, #detailRows, f.detailsContent)
    for index, data in ipairs(detailRows) do
        local row = f.detailRows[index]
        local tracked = self:IsTracked(data.trackKey)

        row:Show()
        row.data = data
        row.trackBtn.data = data
        row.prevBtn.data = data
        row.nextBtn.data = data

        row.icon:SetTexture(data.icon)
        row.name:SetText(data.name or "?")

        if row.trackIcon then
            row.trackIcon:SetAtlas("uitools-icon-search")
            if tracked then
                row.trackIcon:SetVertexColor(0.2, 1, 0.2)
                row.trackIcon:SetAlpha(1)
            else
                row.trackIcon:SetVertexColor(1, 1, 1)
                row.trackIcon:SetAlpha(0.65)
            end
        end

        row.need:SetText(tostring(data.need or 0))
        row.have:SetText(tostring(data.have or 0))
        row.prevBtn:SetShown(data.canCycle)
        row.nextBtn:SetShown(data.canCycle)

        local showChoice = data.canCycle or data.autoCheap or ((data.choiceText or "") ~= "")
        row.choice:SetShown(showChoice)

        if data.autoCheap then
            row.choice:SetText(T("RL_GRADE_AUTO", "Auto"))
        else
            row.choice:SetText(data.choiceText or "")
        end

        if data.enough then
            row.have:SetTextColor(0.2, 1, 0.2)
            row.state:SetAtlas("common-icon-checkmark")
        else
            row.have:SetTextColor(1, 0.25, 0.25)
            row.state:SetAtlas("common-icon-redx")
        end
    end
    for index = #detailRows + 1, #f.detailRows do
        f.detailRows[index]:Hide()
        f.detailRows[index].data = nil
        f.detailRows[index].trackBtn.data = nil
        f.detailRows[index].prevBtn.data = nil
        f.detailRows[index].nextBtn.data = nil
    end
    f.detailsContent:SetHeight(math.max(1, #detailRows * (ROW_H + ROW_GAP)))

    if selectedEntry then
        local concentrationSuffix = GetSelectedRecipeConcentrationSuffix(selectedEntry)
        local priceSuffix = GetRecipeEvaluationSuffix(selectedEntry)
        f.recipeLine:SetText(string.format(T("RL_SELECTED_RECIPE", "Selected recipe: %s"), selectedEntry.recipeName or "?") .. concentrationSuffix .. priceSuffix)
        f.qtyInput:SetText(tostring(selectedEntry.quantity or 1))
        f.cheapCheck:SetChecked(selectedEntry.autoCheap == true)
        f.concCheck:SetChecked(selectedEntry.useConcentration == true)
    elseif #queue > 0 then
        f.recipeLine:SetText(T("RL_SELECTED_RECIPE_ALL", "Selected recipe: All recipes"))
        f.qtyInput:SetText("1")
        f.cheapCheck:SetChecked(false)
        f.concCheck:SetChecked(false)
    else
        f.recipeLine:SetText(T("RL_NO_RECIPE", "Choose a recipe in the profession book"))
        f.qtyInput:SetText("1")
        f.cheapCheck:SetChecked(false)
        f.concCheck:SetChecked(false)
    end

    local totalNeed, totalHave, totalMissing, totalQueueMissing = 0, 0, 0, 0
    for _, row in ipairs(detailRows) do
        totalNeed = totalNeed + (row.need or 0)
        totalHave = totalHave + math.min(row.need or 0, row.have or 0)
    end
    for _, row in ipairs(visibleMissingMaterials or {}) do
        totalMissing = totalMissing + (tonumber(row.quantity) or 0)
    end
    for _, row in ipairs(totalMissingMaterials or {}) do
        totalQueueMissing = totalQueueMissing + (tonumber(row.quantity) or 0)
    end

    local queuePriceText = GetQueueAuctionatorSummaryText(queue)
    f.summary:SetText(string.format(
        T("RL_SUMMARY", "Queue: %d recipes  |  Crafts: %d  |  Total need: %d  |  In bags: %d  |  Queue missing: %d  |  %s  |  Alt + wheel — scale"),
        queueSummary.totalRecipes or 0,
        queueSummary.totalCrafts or 0,
        totalNeed,
        totalHave,
        totalMissing,
        queuePriceText
    ))

    local hasSelected = selectedEntry ~= nil
    f.removeBtn:SetEnabled(hasSelected)
    f.clearBtn:SetEnabled(#queue > 0)
    f.buyBtn:SetEnabled(#visibleQueue > 0 and Shopping and Shopping.IsAvailable and Shopping:IsAvailable() and totalMissing > 0)
    f.craftBtn:SetEnabled(hasSelected)
    f.craftAllBtn:SetEnabled(hasSelected)
    f.minusBtn:SetEnabled(hasSelected)
    f.plusBtn:SetEnabled(hasSelected)
    f.cheapCheck:SetEnabled(hasSelected)
    f.concCheck:SetEnabled(hasSelected)
    if f.qtyInput.EnableMouse then
        f.qtyInput:EnableMouse(hasSelected)
    end
end

function M:EnsureButton()
    local form = GetProfessionForm()
    if not form then
        if self.openButton then
            self.openButton:Hide()
        end
        if self.addButton then
            self.addButton:Hide()
        end
        return
    end

    if not self.openButton then
        local btn = CreateFrame("Button", "AhUIRecipeListOpenButton", form, "UIPanelButtonTemplate")
        btn:SetSize(96, 22)
        btn:SetText("AhUIList")
        btn:SetScript("OnClick", function()
            M:ShowWindow()
        end)
        btn:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(T("RL_BUTTON_TOOLTIP_TITLE", "AhUIList"), 1, 1, 1)
            GameTooltip:AddLine(T("RL_OPEN_TOOLTIP_1", "Opens the AhUIList window."), 0.9, 0.9, 0.9, true)
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function()
            GameTooltip:Hide()
        end)
        self.openButton = btn
    end

    if not self.addButton then
        local btn = CreateFrame("Button", "AhUIRecipeListAddButton", form, "UIPanelButtonTemplate")
        btn:SetSize(22, 22)
        btn:SetText("+")
        btn:SetScript("OnClick", function()
            M:AddSelectedRecipeToQueue()
        end)
        btn:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(T("RL_ADD_BUTTON_TOOLTIP_TITLE", "Add recipe"), 1, 1, 1)
            GameTooltip:AddLine(T("RL_ADD_BUTTON_TOOLTIP_1", "Adds the selected recipe to the craft queue."), 0.9, 0.9, 0.9, true)
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function()
            GameTooltip:Hide()
        end)
        self.addButton = btn
    end

    if self.openButton:GetParent() ~= form then
        self.openButton:SetParent(form)
        self.openButton:ClearAllPoints()
        self.openButton._anchored = nil
    end

    if self.addButton:GetParent() ~= form then
        self.addButton:SetParent(form)
        self.addButton:ClearAllPoints()
        self.addButton._anchored = nil
    end

    if not self.addButton._anchored then
        self.addButton:SetPoint("TOPRIGHT", form, "TOPRIGHT", -260, -60)
        self.addButton._anchored = true
    end

    if not self.openButton._anchored then
        self.openButton:SetPoint("RIGHT", self.addButton, "LEFT", -4, 0)
        self.openButton._anchored = true
    end

    local selected = GetSelectedRecipeInfo()
    self.openButton:SetEnabled(true)
    self.addButton:SetEnabled(selected ~= nil)
    self.openButton:Show()
    self.addButton:Show()
end

local function CreatePGButtonBorder(button)
    local border = CreateFrame("Frame", nil, button, "BackdropTemplate")
    border:SetPoint("TOPLEFT", -1, 1)
    border:SetPoint("BOTTOMRIGHT", 1, -1)
    border:SetFrameLevel(button:GetFrameLevel() - 1)
    border:SetBackdrop({
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    border:SetBackdropBorderColor(1, 1, 1, 0.15)
    button._pgBorder = border
    return border
end

local function HideTextureObject(tex)
    if not tex then return end
    if tex.SetTexture then
        tex:SetTexture(nil)
    end
    if tex.SetAtlas then
        pcall(tex.SetAtlas, tex, nil)
    end
    tex:SetAlpha(0)
    tex:Hide()
end

local function HideTemplateButtonRegions(btn)
    if not btn or btn._rlTemplateHidden then return end

    for _, region in ipairs({ btn:GetRegions() }) do
        if region and region.GetObjectType and region:GetObjectType() == "Texture" then
            HideTextureObject(region)
        end
    end

    btn._rlTemplateHidden = true
end

local function ApplyPGHover(button, bgTexture, textObject, opts)
    if not button or not bgTexture then return end
    opts = opts or {}

    local normalAlpha = opts.normalAlpha or 0.06
    local hoverAlpha = opts.hoverAlpha or 0.12
    local normalColor = opts.normalColor or { 1, 1, 1 }
    local hoverColor = opts.hoverColor or { 1, 1, 1 }
    local disabledAlpha = opts.disabledAlpha or normalAlpha

    if not button._pgBorder then
        CreatePGButtonBorder(button)
    end

    button._pgTheme = {
        bg = bgTexture,
        text = textObject,
        normalAlpha = normalAlpha,
        hoverAlpha = hoverAlpha,
        disabledAlpha = disabledAlpha,
        normalColor = normalColor,
        hoverColor = hoverColor,
    }

    local function Refresh(self)
        local theme = self._pgTheme
        if not theme then return end

        local enabled = (not self.IsEnabled) or self:IsEnabled()
        local over = self:IsMouseOver()

        if not enabled then
            theme.bg:SetColorTexture(1, 1, 1, theme.disabledAlpha)
            if theme.text then
                theme.text:SetTextColor(theme.normalColor[1], theme.normalColor[2], theme.normalColor[3], 0.55)
            end
            if self._pgBorder then
                self._pgBorder:SetBackdropBorderColor(1, 1, 1, 0.10)
            end
            return
        end

        theme.bg:SetColorTexture(1, 1, 1, over and theme.hoverAlpha or theme.normalAlpha)

        if theme.text then
            local c = over and theme.hoverColor or theme.normalColor
            theme.text:SetTextColor(c[1], c[2], c[3], 1)
        end

        if self._pgBorder then
            self._pgBorder:SetBackdropBorderColor(1, 1, 1, over and 0.35 or 0.15)
        end
    end

    if not button._pgHooked then
        button:HookScript("OnEnter", Refresh)
        button:HookScript("OnLeave", Refresh)
        button:HookScript("OnMouseDown", Refresh)
        button:HookScript("OnMouseUp", Refresh)
        button:HookScript("OnShow", Refresh)
        button:HookScript("OnEnable", Refresh)
        button:HookScript("OnDisable", Refresh)
        button._pgHooked = true
    end

    Refresh(button)
end

local function EnsurePGButtonVisual(btn)
    if not btn then return nil end
    if btn._pgVisual then return btn._pgVisual end

    HideTemplateButtonRegions(btn)

    local bg = btn:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(1, 1, 1, 0.08)

    local text = btn.text or btn.Text or btn:GetFontString()
    btn._pgVisual = { bg = bg, text = text }

    ApplyPGHover(btn, bg, text, {
        normalAlpha = 0.08,
        hoverAlpha = 0.16,
    })

    return btn._pgVisual
end

local function EnsurePGPrimaryVisual(btn)
    if not btn then return nil end
    if btn._pgPrimaryVisual then return btn._pgPrimaryVisual end

    HideTemplateButtonRegions(btn)

    local bg = btn:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.1, 0.4, 0.9, 0.25)

    local glow = CreateFrame("Frame", nil, btn, "BackdropTemplate")
    glow:SetPoint("TOPLEFT", btn, "TOPLEFT", -3, 3)
    glow:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 3, -3)
    glow:SetFrameLevel(btn:GetFrameLevel() - 1)
    glow:SetBackdrop({
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 2,
    })
    glow:SetBackdropBorderColor(0.3, 0.7, 1, 0.9)

    local ag = glow:CreateAnimationGroup()
    ag:SetLooping("BOUNCE")
    local anim = ag:CreateAnimation("Alpha")
    anim:SetFromAlpha(0.4)
    anim:SetToAlpha(1.0)
    anim:SetDuration(1.2)
    ag:Play()

    local text = btn.text or btn.Text or btn:GetFontString()
    btn._pgPrimaryVisual = { bg = bg, text = text, glow = glow }

    ApplyPGHover(btn, bg, text, {
        normalAlpha = 0.25,
        hoverAlpha = 0.38,
    })

    return btn._pgPrimaryVisual
end

local function EnsureTabVisual(btn)
    if not btn then return nil end
    if btn._rlTabVisual then return btn._rlTabVisual end

    HideTemplateButtonRegions(btn)

    local bg = btn:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(1, 1, 1, 0.06)

    local text = btn.Text or btn:GetFontString()

    btn._rlTabVisual = {
        bg = bg,
        text = text,
    }

    if not btn._pgBorder then
        CreatePGButtonBorder(btn)
    end

    return btn._rlTabVisual
end

local function UpdateTabVisual(btn)
    if not btn then return end

    local v = EnsureTabVisual(btn)
    local active = not btn:IsEnabled()
    local over = btn:IsMouseOver()
    local pulsing = btn._rlPulseUntil and GetTime() < btn._rlPulseUntil

    if active then
        v.bg:SetColorTexture(0.3, 0.7, 1, 0.22)
        if btn._pgBorder then
            btn._pgBorder:SetBackdropBorderColor(0.45, 0.75, 1, 0.65)
        end
    elseif pulsing then
        v.bg:SetColorTexture(0.3, 0.7, 1, 0.18)
        if btn._pgBorder then
            btn._pgBorder:SetBackdropBorderColor(0.45, 0.75, 1, 0.9)
        end
    else
        v.bg:SetColorTexture(1, 1, 1, over and 0.12 or 0.06)
        if btn._pgBorder then
            btn._pgBorder:SetBackdropBorderColor(1, 1, 1, over and 0.35 or 0.15)
        end
    end

    if v.text then
        v.text:ClearAllPoints()
        v.text:SetPoint("CENTER", 0, 0)

        if active or pulsing then
            v.text:SetTextColor(1, 1, 1)
        else
            v.text:SetTextColor(0.92, 0.92, 0.92)
        end
    end
end

local function StyleTabButton(btn)
    if not btn then return end

    EnsureTabVisual(btn)

    if not btn._rlTabHooked then
        btn:HookScript("OnShow", UpdateTabVisual)
        btn:HookScript("OnEnter", UpdateTabVisual)
        btn:HookScript("OnLeave", UpdateTabVisual)
        btn:HookScript("OnEnable", UpdateTabVisual)
        btn:HookScript("OnDisable", UpdateTabVisual)
        btn._rlTabHooked = true
    end

    UpdateTabVisual(btn)
end

local function StripDropdownArrow(btn)
    if not btn then return end
    local text = btn:GetText()
    if text and text ~= "" then
        btn:SetText(text:gsub("%s*▼%s*$", ""))
    end
end

local function ApplyMainWindowSkin(self)
    local f = self and self.frame
    if not f then return end

    if f.tabChars then StyleTabButton(f.tabChars) end
    if f.tabQueue then StyleTabButton(f.tabQueue) end

    if f.scanBtn then
        EnsurePGPrimaryVisual(f.scanBtn)
        f.scanBtn:SetText(T("RL_SCAN_CURRENT", "Scan"))
    end

    local flatButtons = {
        f.trackersBtn,
        f.scanAddBtn,
        f.scanEpochBtn,
        f.scanCategoryBtn,
        f.scanRecipeValueBtn,
        f.scanRecipePriceBtn,
        f.scanRecipeProfitBtn,
        f.removeBtn,
        f.clearBtn,
        f.buyBtn,
        f.craftBtn,
        f.craftAllBtn,
    }

    for _, btn in ipairs(flatButtons) do
        if btn then
            EnsurePGButtonVisual(btn)
        end
    end

    if f.scanEpochBtn then StripDropdownArrow(f.scanEpochBtn) end
    if f.scanCategoryBtn then StripDropdownArrow(f.scanCategoryBtn) end
end

local function ApplyDynamicRowSkin(self)
    local f = self and self.frame
    if not f then return end

    for _, row in ipairs(f.scanRecipeRows or {}) do
        if row and row.addBtn then
            EnsurePGButtonVisual(row.addBtn)
        end
    end

    if f.tabChars then UpdateTabVisual(f.tabChars) end
    if f.tabQueue then UpdateTabVisual(f.tabQueue) end
    if f.scanEpochBtn then StripDropdownArrow(f.scanEpochBtn) end
    if f.scanCategoryBtn then StripDropdownArrow(f.scanCategoryBtn) end
end

if not M._ahuiThemePatchInstalled then
    hooksecurefunc(M, "CreateWindow", function(self)
        ApplyMainWindowSkin(self)
        ApplyDynamicRowSkin(self)
    end)

    hooksecurefunc(M, "RefreshWindow", function(self)
        ApplyMainWindowSkin(self)
        ApplyDynamicRowSkin(self)
    end)

    M._ahuiThemePatchInstalled = true
end
