GuildHelper = GuildHelper or {}

local addon = GuildHelper

addon.NewUIBIS = addon.NewUIBIS or {}

local page = addon.NewUIBIS
page.State = page.State or {}
local refreshPage
local createGearEditorInput
local getGearRowTextColor
local clearChildren

local function isBISPageActive()
    return addon.NewUIShell and addon.NewUIShell.activePage == "bis"
end

local function installMainFrameBridge()
    if page._bridgeInstalled or not addon.MainFrame then
        return
    end

    page._bridgeInstalled = true
    page._originalRefreshCurrentView = addon.MainFrame.RefreshCurrentView
    addon.MainFrame.RefreshCurrentView = function(self, ...)
        if isBISPageActive() and page._parentFrame then
            refreshPage(page._parentFrame, page._snapshot)
            return
        end
        if page._originalRefreshCurrentView then
            return page._originalRefreshCurrentView(self, ...)
        end
    end
end

local PANEL_ART_KEY = "MyToonsPanelBackground"
local PANEL_HEADER_KEY = "HomePanelHeader"
local CARD_ART_KEY = "MyToonsCardBackground"
local BUTTON_ART_KEY = "MyToonsButtonBackground"
local BUTTON_ACTIVE_ART_KEY = "MyToonsButtonSelected"
local CLASS_ICON_TEXTURE = CLASS_ICON_TEXTURE or "Interface\\GLUES\\CHARACTERCREATE\\UI-CHARACTERCREATE-CLASSES"

local BIS_ROW_VISUALS = {
    ["below-champion"] = { baseColor = { 0.18, 0.18, 0.20, 0.96 }, borderColor = { 0.56, 0.58, 0.62, 1 }, textColor = { 0.90, 0.90, 0.92, 1 } },
    ["champion-bis"] = { baseColor = { 0.08, 0.16, 0.32, 0.96 }, borderColor = { 0.34, 0.62, 0.98, 1 }, textColor = { 0.88, 0.93, 1, 1 } },
    ["empty"] = { baseColor = { 0.16, 0.13, 0.08, 0.94 }, borderColor = { 0.52, 0.40, 0.18, 1 }, textColor = { 0.90, 0.84, 0.72, 1 } },
    ["heroic-bis"] = { baseColor = { 0.14, 0.12, 0.34, 0.96 }, borderColor = { 0.48, 0.52, 0.98, 1 }, textColor = { 0.91, 0.90, 1, 1 } },
    ["missing"] = { baseColor = { 0.29, 0.24, 0.08, 0.96 }, borderColor = { 0.90, 0.78, 0.28, 1 }, textColor = { 0.98, 0.94, 0.78, 1 } },
    ["mythic-bis"] = { baseColor = { 0.26, 0.08, 0.32, 0.96 }, borderColor = { 0.76, 0.36, 0.96, 1 }, textColor = { 0.97, 0.90, 1, 1 } },
    ["optional"] = { baseColor = { 0.08, 0.24, 0.12, 0.96 }, borderColor = { 0.34, 0.82, 0.44, 1 }, textColor = { 0.90, 0.98, 0.90, 1 } },
}

local function trimString(value)
    return tostring(value or ""):match("^%s*(.-)%s*$") or ""
end

local function lowerText(value)
    return string.lower(tostring(value or ""))
end

local function cropText(value, maxLength)
    local text = tostring(value or "")
    local limit = math.max(0, tonumber(maxLength) or 0)

    if limit <= 0 or string.len(text) <= limit then
        return text
    end

    if limit <= 3 then
        return string.sub(text, 1, limit)
    end

    return string.sub(text, 1, limit - 3) .. "..."
end

local function ApplyRect(frame, parent, rect)
    frame:ClearAllPoints()
    frame:SetPoint("TOPLEFT", parent, "TOPLEFT", tonumber(rect.x) or 0, tonumber(rect.y) or 0)
    frame:SetSize(tonumber(rect.w) or 1, tonumber(rect.h) or 1)
end

local function getClassColor(classFile)
    local colors = RAID_CLASS_COLORS or CUSTOM_CLASS_COLORS
    local token = string.upper(trimString(classFile or ""))
    local color = colors and colors[token] or nil
    if color then
        return color.r, color.g, color.b, 1
    end
    return 0.96, 0.93, 0.84, 1
end

local function splitFullName(fullName)
    local name, realm = tostring(fullName or ""):match("^([^-]+)%-(.+)$")
    return trimString(name) ~= "" and name or trimString(fullName), trimString(realm)
end

local function resolveTexturePath(textureKey)
    local textures = addon.NewUIAssets and addon.NewUIAssets.Textures or nil
    return textures and textures[textureKey] or nil
end

local function resolveArtMetrics(textureKey)
    local metrics = addon.NewUIAssets and addon.NewUIAssets.Metrics or nil
    return metrics and metrics[textureKey] or nil
end

local function applyArtTexture(texture, textureKey, fallbackColor)
    local texturePath = resolveTexturePath(textureKey)
    local metrics = resolveArtMetrics(textureKey)
    if texturePath then
        texture:SetTexture(texturePath)
        texture:SetVertexColor(1, 1, 1, 0.98)
        if metrics and type(metrics.texCoord) == "table" and #metrics.texCoord == 4 then
            texture:SetTexCoord(metrics.texCoord[1], metrics.texCoord[3], metrics.texCoord[2], metrics.texCoord[4])
        end
        return
    end
    texture:SetColorTexture(unpack(fallbackColor or { 0.10, 0.10, 0.12, 0.95 }))
end

local function getConfiguredBISPopupTransparencyAlpha()
    if addon.NewUISettings and addon.NewUISettings.GetConfiguredTransparencyAlpha then
        return math.max(0.60, math.min(1, tonumber(addon.NewUISettings:GetConfiguredTransparencyAlpha("bisPopup")) or 1))
    end

    return 1
end

local function getCardValueColor(backgroundTextureKey)
    if backgroundTextureKey == "HomeRaidCardBackground" then
        return 0.96, 0.82, 0.34, 1
    end
    if backgroundTextureKey == "HomeMythicCardBackground" then
        return 0.86, 0.70, 1.00, 1
    end
    if backgroundTextureKey == "HomeMembersCardBackground" then
        return 0.62, 0.96, 0.54, 1
    end
    if backgroundTextureKey == "HomeNewsCardBackground" then
        return 0.48, 0.78, 1.00, 1
    end
    return 0.96, 0.82, 0.34, 1
end

local function getArtInsets(textureKey, insetKey)
    local metrics = resolveArtMetrics(textureKey) or nil
    local fallbackInsets = addon.NewUIAssets and addon.NewUIAssets.Insets and addon.NewUIAssets.Insets[insetKey] or nil
    local insets = metrics and metrics.insets or fallbackInsets or {}
    return tonumber(insets.left) or 0, tonumber(insets.right) or 0, tonumber(insets.top) or 0, tonumber(insets.bottom) or 0
end

local function clearFrame(frame)
    if not frame then
        return
    end
    if frame.Hide then
        frame:Hide()
    end
    if frame.SetParent then
        frame:SetParent(nil)
    end
end

local function enableMouseWheelScroll(scrollFrame, step)
    if not scrollFrame or not scrollFrame.EnableMouseWheel then
        return
    end

    scrollFrame:EnableMouseWheel(true)
    scrollFrame:SetScript("OnMouseWheel", function(self, delta)
        local nextValue = (tonumber(self:GetVerticalScroll()) or 0) - (delta * (tonumber(step) or 24))
        local maxValue = tonumber(self:GetVerticalScrollRange()) or 0
        nextValue = math.max(0, math.min(nextValue, maxValue))
        self:SetVerticalScroll(nextValue)
        if self.ScrollBar and self.ScrollBar.SetValue then
            self.ScrollBar:SetValue(nextValue)
        end
    end)
end

local function createSurface(parent, rect, textureKey)
    local frame = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    ApplyRect(frame, parent, rect)
    if frame.SetBackdrop then
        frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
        frame:SetBackdropColor(0, 0, 0, 0)
        frame:SetBackdropBorderColor(0, 0, 0, 0)
    end
    local background = frame:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(frame)
    applyArtTexture(background, textureKey or CARD_ART_KEY, { 0.09, 0.10, 0.13, 0.94 })
    frame.Background = background
    return frame
end

local function createPanel(parent, rect, titleText, metaText)
    local panel = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    ApplyRect(panel, parent, rect)
    if panel.SetBackdrop then
        panel:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
        panel:SetBackdropColor(0, 0, 0, 0)
        panel:SetBackdropBorderColor(0, 0, 0, 0)
    end
    local background = panel:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(panel)
    applyArtTexture(background, PANEL_ART_KEY, { 0.09, 0.10, 0.13, 0.94 })
    local headerBand = panel:CreateTexture(nil, "ARTWORK")
    headerBand:SetPoint("TOPLEFT", panel, "TOPLEFT", 24, -14)
    headerBand:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -24, -14)
    headerBand:SetHeight(24)
    applyArtTexture(headerBand, PANEL_HEADER_KEY, { 0.08, 0.08, 0.10, 0.82 })
    headerBand:SetVertexColor(1, 1, 1, 0.98)
    local title = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("CENTER", headerBand, "CENTER", 0, -1)
    title:SetText(titleText or "")
    title:SetTextColor(0.98, 0.92, 0.76, 1)
    if trimString(metaText) ~= "" then
        local meta = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        meta:SetPoint("TOPLEFT", panel, "TOPLEFT", 14, -46)
        meta:SetWidth(math.max(1, (tonumber(rect.w) or 1) - 28))
        meta:SetJustifyH("LEFT")
        meta:SetText(metaText)
        meta:SetTextColor(0.76, 0.72, 0.64, 1)
        panel.Meta = meta
    end
    panel.Title = title
    return panel
end

local function createButton(parent, rect, labelText, onClick, isActive)
    local button = CreateFrame("Button", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    ApplyRect(button, parent, rect)
    local background = button:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(button)
    applyArtTexture(background, isActive and BUTTON_ACTIVE_ART_KEY or BUTTON_ART_KEY, isActive and { 0.20, 0.23, 0.31, 0.98 } or { 0.10, 0.11, 0.14, 0.95 })
    local text = button:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    text:SetPoint("CENTER", button, "CENTER", 0, -1)
    text:SetText(labelText or "")
    text:SetTextColor(isActive and 0.98 or 0.96, isActive and 0.88 or 0.93, isActive and 0.58 or 0.84, 1)
    button.Background = background
    button.Text = text
    button:SetScript("OnClick", function()
        if onClick then
            onClick(button)
        end
    end)
    return button
end

local function createSelectButton(parent, rect, textValue, onClick)
    local button = createButton(parent, rect, textValue, onClick, false)
    local arrow = button:CreateTexture(nil, "OVERLAY")
    arrow:SetPoint("RIGHT", button, "RIGHT", -8, 0)
    arrow:SetSize(16, 16)
    arrow:SetTexture("Interface\\ChatFrame\\ChatFrameExpandArrow")
    arrow:SetVertexColor(0.96, 0.82, 0.46, 1)
    button.Arrow = arrow
    return button
end

local function createTextureButton(parent, size, normalTextureKey, hoverTextureKey, onClick)
    local button = CreateFrame("Button", nil, parent)
    button:SetSize(size, size)

    local texture = button:CreateTexture(nil, "ARTWORK")
    texture:SetAllPoints(button)
    applyArtTexture(texture, normalTextureKey, { 1, 1, 1, 1 })
    button.Texture = texture
    button.NormalTextureKey = normalTextureKey
    button.HoverTextureKey = hoverTextureKey or normalTextureKey

    button:SetScript("OnEnter", function(self)
        applyArtTexture(self.Texture, self.HoverTextureKey, { 1, 1, 1, 1 })
    end)
    button:SetScript("OnLeave", function(self)
        applyArtTexture(self.Texture, self.NormalTextureKey, { 1, 1, 1, 1 })
    end)
    button:SetScript("OnMouseDown", function(self)
        self.Texture:SetVertexColor(0.88, 0.88, 0.88, 1)
    end)
    button:SetScript("OnMouseUp", function(self)
        self.Texture:SetVertexColor(1, 1, 1, 1)
    end)
    button:SetScript("OnClick", function()
        if onClick then
            onClick(button)
        end
    end)

    return button
end

local function ensurePopupMenuFrame()
    if page._popupMenuFrame then
        return page._popupMenuFrame
    end
    local frame = CreateFrame("Frame", nil, UIParent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    frame:SetFrameStrata("TOOLTIP")
    frame:SetToplevel(true)
    frame:SetClampedToScreen(true)
    frame:Hide()
    frame.itemButtons = {}
    if frame.SetBackdrop then
        frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
        frame:SetBackdropColor(0.05, 0.06, 0.08, 0.96)
        frame:SetBackdropBorderColor(0.44, 0.34, 0.16, 0.95)
    end
    page._popupMenuFrame = frame
    return frame
end

local function showDropdownMenu(anchorFrame, items)
    local frame = ensurePopupMenuFrame()
    local menuItems = type(items) == "function" and (items() or {}) or (items or {})
    local width = math.max(160, tonumber(anchorFrame and anchorFrame:GetWidth()) or 160)
    for index, option in ipairs(menuItems) do
        local button = frame.itemButtons[index] or CreateFrame("Button", nil, frame, BackdropTemplateMixin and "BackdropTemplate" or nil)
        frame.itemButtons[index] = button
        if button.SetBackdrop then
            button:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
            button:SetBackdropColor(0.10, 0.11, 0.14, 0.92)
            button:SetBackdropBorderColor(0.30, 0.24, 0.12, 0.75)
        end
        if not button.Text then
            button.Text = button:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            button.Text:SetPoint("LEFT", button, "LEFT", 8, 0)
            button.Text:SetPoint("RIGHT", button, "RIGHT", -8, 0)
            button.Text:SetJustifyH("LEFT")
        end
        button:SetPoint("TOPLEFT", frame, "TOPLEFT", 8, -8 - ((index - 1) * 24))
        button:SetSize(width, 22)
        button.Text:SetText(string.format("%s%s", option.checked and "* " or "", tostring(option.text or "Select")))
        button.Text:SetTextColor(option.disabled and 0.62 or 0.96, option.disabled and 0.58 or 0.93, option.disabled and 0.52 or 0.84, 1)
        button:SetScript("OnClick", function()
            if option.disabled then
                return
            end
            frame:Hide()
            if type(option.func) == "function" then
                option.func(option.value)
            end
        end)
        if option.disabled then
            button:Disable()
            button:SetAlpha(0.55)
        else
            button:Enable()
            button:SetAlpha(1)
        end
        button:Show()
    end
    for index = #menuItems + 1, #(frame.itemButtons or {}) do
        frame.itemButtons[index]:Hide()
    end
    frame:ClearAllPoints()
    frame:SetPoint("TOPLEFT", anchorFrame, "BOTTOMLEFT", 0, -4)
    frame:SetSize(width + 16, (#menuItems * 24) + 16)
    frame:Show()
end

local function createCard(parent, rect, titleText, valueText, subtitleText, backgroundTextureKey, options)
    local card = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    local centered = type(options) == "table" and options.centered == true
    ApplyRect(card, parent, rect)
    local background = card:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(card)
    applyArtTexture(background, backgroundTextureKey or CARD_ART_KEY, { 0.09, 0.10, 0.13, 0.94 })
    local title = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    if centered then
        title:SetPoint("TOP", card, "TOP", 0, -16)
        title:SetWidth(math.max(1, rect.w - 24))
        title:SetJustifyH("CENTER")
    else
        title:SetPoint("TOPLEFT", card, "TOPLEFT", 18, -18)
    end
    title:SetText(titleText or "")
    title:SetTextColor(0.78, 0.74, 0.66, 1)
    local value = card:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    if centered then
        value:SetPoint("TOP", title, "BOTTOM", 0, -8)
    else
        value:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -8)
    end
    value:SetWidth(math.max(1, rect.w - 28))
    value:SetJustifyH(centered and "CENTER" or "LEFT")
    value:SetWordWrap(false)
    value:SetText(valueText or "-")
    value:SetTextColor(getCardValueColor(backgroundTextureKey or CARD_ART_KEY))
    if trimString(subtitleText) ~= "" then
        local subtitle = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        if centered then
            subtitle:SetPoint("BOTTOM", card, "BOTTOM", 0, 12)
            subtitle:SetJustifyH("CENTER")
        else
            subtitle:SetPoint("BOTTOMLEFT", card, "BOTTOMLEFT", 18, 12)
            subtitle:SetJustifyH("LEFT")
        end
        subtitle:SetWidth(math.max(1, rect.w - 28))
        subtitle:SetText(subtitleText)
        subtitle:SetTextColor(0.72, 0.68, 0.58, 1)
        card.Subtitle = subtitle
    end
    card.Title = title
    card.Value = value
    return card
end

local function createTextDialogOverlay(parent, options, onClose, onAccept)
    local overlay = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    overlay:SetAllPoints(parent)
    overlay:SetFrameStrata(parent:GetFrameStrata())
    overlay:SetFrameLevel((parent:GetFrameLevel() or 0) + 42)

    local dim = overlay:CreateTexture(nil, "BACKGROUND")
    dim:SetAllPoints(overlay)
    dim:SetColorTexture(0.02, 0.02, 0.03, 0.78)

    local dialog = createPanel(overlay, { x = 120, y = -62, w = 1140, h = 620 }, options and options.title or "Dialog", options and options.helpText or "")
    dialog:SetFrameStrata(overlay:GetFrameStrata())
    dialog:SetFrameLevel((overlay:GetFrameLevel() or 0) + 5)

    local inputFrame = createSurface(dialog, { x = 14, y = -88, w = 1112, h = 468 }, "MyToonsRowBackground")
    local scrollFrame = CreateFrame("ScrollFrame", nil, inputFrame, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", inputFrame, "TOPLEFT", 10, -10)
    scrollFrame:SetPoint("BOTTOMRIGHT", inputFrame, "BOTTOMRIGHT", -28, 10)

    local editBox = CreateFrame("EditBox", nil, scrollFrame, "InputBoxTemplate")
    if editBox.Left then editBox.Left:SetAlpha(0) end
    if editBox.Middle then editBox.Middle:SetAlpha(0) end
    if editBox.Right then editBox.Right:SetAlpha(0) end
    editBox:SetMultiLine(true)
    editBox:SetAutoFocus(false)
    editBox:SetFontObject("ChatFontNormal")
    editBox:SetWidth(1048)
    editBox:SetTextInsets(6, 6, 6, 6)
    editBox:SetText(options and options.text or "")
    editBox:SetTextColor(0.96, 0.93, 0.84, 1)
    editBox:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
    end)
    scrollFrame:SetScrollChild(editBox)

    createButton(dialog, { x = 830, y = -560, w = 132, h = 34 }, "Close", onClose, false)
    if onAccept then
        createButton(dialog, { x = 976, y = -560, w = 132, h = 34 }, options and options.acceptText or "Apply", function()
            onAccept(editBox:GetText() or "")
        end, true)
    end

    overlay.Dialog = dialog
    overlay.EditBox = editBox
    if options and options.readOnly then
        editBox:HighlightText()
    else
        editBox:SetFocus()
    end
    return overlay
end

local function createGearSelectorPopupOverlay(parent, state, onClose, onSave)
    local service = addon.BISService
    local overlay = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    overlay:SetAllPoints(parent)
    overlay:SetFrameStrata(parent:GetFrameStrata())
    overlay:SetFrameLevel((parent:GetFrameLevel() or 0) + 42)

    local dim = overlay:CreateTexture(nil, "BACKGROUND")
    dim:SetAllPoints(overlay)
    dim:SetColorTexture(0.02, 0.02, 0.03, 0.78)

    local dialog = createPanel(overlay, { x = 120, y = -62, w = 1140, h = 620 }, state and state.title or "BIS Gear Select", "Pick journal items by slot, paste item IDs if needed, and save the table back into the current BIS list.")
    dialog:SetFrameStrata(overlay:GetFrameStrata())
    dialog:SetFrameLevel((overlay:GetFrameLevel() or 0) + 5)

    createButton(dialog, { x = 936, y = -54, w = 96, h = 24 }, "Clear All", function()
        state.rowsData = buildBlankGearSelectorRows(service)
        dialog:RenderRows()
    end, false)

    local header = CreateFrame("Frame", nil, dialog)
    header:SetPoint("TOPLEFT", dialog, "TOPLEFT", 16, -92)
    header:SetSize(1090, 18)

    for _, info in ipairs({
        { text = "Slot", x = 0, width = 60 },
        { text = "Journal Item", x = 68, width = 240 },
        { text = "ItemID / URL", x = 316, width = 130 },
        { text = "Source", x = 454, width = 88 },
        { text = "Instance", x = 550, width = 150 },
        { text = "Boss", x = 708, width = 140 },
        { text = "Notes", x = 856, width = 180 },
    }) do
        local label = header:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("TOPLEFT", header, "TOPLEFT", info.x, 0)
        label:SetWidth(info.width)
        label:SetJustifyH("LEFT")
        label:SetText(info.text)
        label:SetTextColor(0.82, 0.78, 0.68, 1)
    end

    local scrollFrame = CreateFrame("ScrollFrame", nil, dialog, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", dialog, "TOPLEFT", 16, -112)
    scrollFrame:SetPoint("BOTTOMRIGHT", dialog, "BOTTOMRIGHT", -34, 52)

    local scrollContent = CreateFrame("Frame", nil, scrollFrame)
    scrollContent:SetSize(1088, 100)
    scrollFrame:SetScrollChild(scrollContent)
    enableMouseWheelScroll(scrollFrame, 34)

    function dialog:ApplyPreview(row, options)
        local resolvedOptions = type(options) == "table" and options or {}
        local journalSpecId = trimString(state.selectedLootSpecId) ~= "" and state.selectedLootSpecId or state.selectedSpecId
        local preview = service:BuildGearEntryPreview(state.toonId, journalSpecId, row.slotKey, {
            itemId = row.itemId,
            itemName = row.itemName,
            notes = row.notes,
            priority = row.priority,
            sourceName = row.sourceName,
            sourceTarget = row.sourceTarget,
            sourceType = row.sourceType,
            __options = { currentSeasonOnly = true, forceSourceAutofill = resolvedOptions.forceSourceAutofill and true or false },
        })

        if (tonumber(preview.itemId) or 0) > 0 then row.itemId = tostring(preview.itemId) end
        if trimString(preview.itemName) ~= "" then row.itemName = preview.itemName end
        if trimString(preview.sourceType) ~= "" and preview.sourceType ~= "all" then row.sourceType = preview.sourceType end
        if resolvedOptions.forceSourceAutofill or trimString(preview.sourceName) ~= "" then row.sourceName = trimString(preview.sourceName) end
        if resolvedOptions.forceSourceAutofill or trimString(preview.sourceTarget) ~= "" then row.sourceTarget = trimString(preview.sourceTarget) end
    end

    function dialog:RenderRows()
        clearChildren(scrollContent)
        local rows = state.rowsData or {}
        local rowHeight = 34
        local popupInputs = {}
        local popupInputGrid = {}

        local function focusPopupInput(nextIndex)
            if popupInputs[nextIndex] and popupInputs[nextIndex].SetFocus then
                popupInputs[nextIndex]:SetFocus()
            end
        end

        local function focusPopupInputByGrid(nextRowIndex, columnIndex)
            local totalRows = #rows
            if totalRows <= 0 then
                return
            end

            local resolvedRowIndex = nextRowIndex
            if resolvedRowIndex > totalRows then
                resolvedRowIndex = 1
            elseif resolvedRowIndex < 1 then
                resolvedRowIndex = totalRows
            end

            local targetRow = popupInputGrid[resolvedRowIndex]
            local targetInput = targetRow and targetRow[columnIndex] or nil
            if targetInput and targetInput.SetFocus then
                targetInput:SetFocus()
            end
        end

        local function registerPopupInput(input, rowIndex, columnIndex, rerenderOnBlur)
            if not input then
                return
            end

            popupInputs[#popupInputs + 1] = input
            local currentIndex = #popupInputs
            popupInputGrid[rowIndex] = popupInputGrid[rowIndex] or {}
            popupInputGrid[rowIndex][columnIndex] = input

            input:SetScript("OnTabPressed", function(self)
                local totalInputs = #popupInputs
                if totalInputs <= 0 then
                    return
                end

                local nextIndex = currentIndex + 1
                if IsShiftKeyDown and IsShiftKeyDown() then
                    nextIndex = currentIndex - 1
                end

                if nextIndex > totalInputs then
                    nextIndex = 1
                elseif nextIndex < 1 then
                    nextIndex = totalInputs
                end

                if rerenderOnBlur then
                    dialog._pendingTabFocusIndex = nextIndex
                    self:ClearFocus()
                    return
                end

                self:ClearFocus()
                focusPopupInput(nextIndex)
            end)

            input:SetScript("OnEnterPressed", function(self)
                local totalRows = #rows
                if totalRows <= 0 then
                    return
                end

                local nextRowIndex = rowIndex + 1
                if nextRowIndex > totalRows then
                    nextRowIndex = 1
                end

                if rerenderOnBlur then
                    dialog._pendingPopupGridFocus = { rowIndex = nextRowIndex, columnIndex = columnIndex }
                    self:ClearFocus()
                    return
                end

                self:ClearFocus()
                focusPopupInputByGrid(nextRowIndex, columnIndex)
            end)
        end

        scrollContent:SetSize(1088, math.max(120, (#rows * rowHeight) + 8))

        for index, row in ipairs(rows) do
            local rowFrame = createSurface(scrollContent, { x = 0, y = -((index - 1) * rowHeight), w = 1068, h = 30 }, "MyToonsRowBackground")
            local rowTextColor = getGearRowTextColor(row)

            local slotText = rowFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            slotText:SetPoint("LEFT", rowFrame, "LEFT", 0, 0)
            slotText:SetWidth(60)
            slotText:SetJustifyH("LEFT")
            slotText:SetText(row.slotLabel or row.slotKey or "Slot")
            slotText:SetTextColor(rowTextColor[1], rowTextColor[2], rowTextColor[3], rowTextColor[4] or 1)

            local itemButton = createButton(rowFrame, { x = 50, y = -1, w = 252, h = 22 }, trimString(row.itemName) ~= "" and cropText(row.itemName, 28) or "Select Item", function()
                page:ShowJournalItemPickerDialog(string.format("%s Items", row.slotLabel or "BIS"), { currentSeasonOnly = true, slotKey = row.slotKey, sourceType = row.sourceType, specId = trimString(state.selectedLootSpecId) ~= "" and state.selectedLootSpecId or state.selectedSpecId, toonId = state.toonId }, function(option)
                    row.itemId = (tonumber(option.itemId) or 0) > 0 and tostring(option.itemId) or ""
                    row.itemName = option.itemName or ""
                    row.sourceType = option.sourceType or row.sourceType
                    row.sourceName = option.sourceName or ""
                    row.sourceTarget = option.sourceTarget or ""
                    dialog:RenderRows()
                end)
            end, false)
            itemButton.Text:SetTextColor(rowTextColor[1], rowTextColor[2], rowTextColor[3], rowTextColor[4] or 1)

            local _, itemIdInput = createGearEditorInput(rowFrame, { x = 316, y = -1, w = 130, h = 22 }, row.itemId or "", "LEFT")
            itemIdInput:SetNumeric(false)
            itemIdInput:SetScript("OnEditFocusGained", function(edit) edit._gh3OriginalText = edit:GetText() end)
            itemIdInput:SetScript("OnTextChanged", function(edit) row.itemId = edit:GetText() end)
            itemIdInput:SetScript("OnEditFocusLost", function(edit)
                row.itemId = edit:GetText()
                dialog:ApplyPreview(row, { forceSourceAutofill = trimString(edit._gh3OriginalText) ~= trimString(edit:GetText()) })
                dialog:RenderRows()
            end)
            registerPopupInput(itemIdInput, index, 1, true)

            local sourceButton = createSelectButton(rowFrame, { x = 444, y = -1, w = 104, h = 22 }, service:GetSourceTypeLabel(row.sourceType), function(anchor)
                showDropdownMenu(anchor, function()
                    local items = {}
                    for _, option in ipairs(service:GetSourceTypeOptions() or {}) do
                        if option.id ~= "all" then
                            items[#items + 1] = { text = option.label, value = option.id, checked = row.sourceType == option.id, func = function(value) row.sourceType = value; dialog:RenderRows() end }
                        end
                    end
                    return items
                end)
            end)
            sourceButton.Text:SetTextColor(rowTextColor[1], rowTextColor[2], rowTextColor[3], rowTextColor[4] or 1)

            local _, sourceNameInput = createGearEditorInput(rowFrame, { x = 550, y = -1, w = 150, h = 22 }, row.sourceName or "", "LEFT")
            sourceNameInput:SetScript("OnTextChanged", function(edit) row.sourceName = edit:GetText() end)
            registerPopupInput(sourceNameInput, index, 2, false)

            local _, sourceTargetInput = createGearEditorInput(rowFrame, { x = 708, y = -1, w = 140, h = 22 }, row.sourceTarget or "", "LEFT")
            sourceTargetInput:SetScript("OnTextChanged", function(edit) row.sourceTarget = edit:GetText() end)
            registerPopupInput(sourceTargetInput, index, 3, false)

            local _, notesInput = createGearEditorInput(rowFrame, { x = 856, y = -1, w = 180, h = 22 }, row.notes or "", "LEFT")
            notesInput:SetScript("OnTextChanged", function(edit) row.notes = edit:GetText() end)
            registerPopupInput(notesInput, index, 4, false)

            createButton(rowFrame, { x = 1042, y = -1, w = 22, h = 22 }, "+", function()
                table.insert(state.rowsData, index + 1, { itemId = "", itemName = "", notes = "", priority = row.priority or "bis", slotKey = row.slotKey, slotLabel = row.slotLabel, sourceName = "", sourceTarget = "", sourceType = row.sourceType ~= "" and row.sourceType or "all" })
                dialog:RenderRows()
            end, false)

            createButton(rowFrame, { x = 1066, y = -1, w = 22, h = 22 }, "-", function()
                table.remove(state.rowsData, index)
                if #state.rowsData <= 0 then state.rowsData = buildBlankGearSelectorRows(service) end
                dialog:RenderRows()
            end, false)
        end

        if dialog._pendingTabFocusIndex then
            focusPopupInput(dialog._pendingTabFocusIndex)
            dialog._pendingTabFocusIndex = nil
        elseif dialog._pendingPopupGridFocus then
            focusPopupInputByGrid(dialog._pendingPopupGridFocus.rowIndex, dialog._pendingPopupGridFocus.columnIndex)
            dialog._pendingPopupGridFocus = nil
        end
    end

    createButton(dialog, { x = 830, y = -560, w = 132, h = 34 }, "Close", onClose, false)
    createButton(dialog, { x = 976, y = -560, w = 132, h = 34 }, "Apply", onSave, true)
    dialog:RenderRows()

    overlay.Dialog = dialog
    return overlay
end

local function createCheckbox(parent, rect, labelText, isChecked, onToggle)
    local button = CreateFrame("Button", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    ApplyRect(button, parent, rect)

    local box = createSurface(button, { x = 0, y = 0, w = 20, h = 20 }, "MyToonsRowBackground")
    local mark = box:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    mark:SetPoint("CENTER", box, "CENTER", 0, 0)
    mark:SetText(isChecked and "X" or "")
    mark:SetTextColor(0.96, 0.82, 0.46, 1)

    local text = button:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    text:SetPoint("LEFT", box, "RIGHT", 10, 0)
    text:SetPoint("RIGHT", button, "RIGHT", 0, 0)
    text:SetJustifyH("LEFT")
    text:SetText(labelText or "")
    text:SetTextColor(0.96, 0.93, 0.84, 1)

    button:SetScript("OnClick", function()
        if onToggle then
            onToggle(not (isChecked and true or false))
        end
    end)

    button.Mark = mark
    button.Label = text
    return button
end

local function createLootHelperOverlay(parent, onClose, onRefresh)
    local service = addon.BISService
    local settings = service and service:GetLootHelperSettings() or {}
    local activeRows = service and service:ListActiveDropReviewRows() or {}
    local historyRows = service and service:ListLootHistory() or {}
    local overlay = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    overlay:SetAllPoints(parent)
    overlay:SetFrameStrata(parent:GetFrameStrata())
    overlay:SetFrameLevel((parent:GetFrameLevel() or 0) + 42)

    local dim = overlay:CreateTexture(nil, "BACKGROUND")
    dim:SetAllPoints(overlay)
    dim:SetColorTexture(0.02, 0.02, 0.03, 0.78)

    local dialog = createPanel(overlay, { x = 400, y = -70, w = 580, h = 618 }, "GH3 Loot Helper", "Configure how GH3 builds the BIS Drops review queue.")
    dialog:SetFrameStrata(overlay:GetFrameStrata())
    dialog:SetFrameLevel((overlay:GetFrameLevel() or 0) + 5)

    local modeButton = createSelectButton(dialog, { x = 14, y = -88, w = 220, h = 24 }, "Queue Mode", function(anchor)
        showDropdownMenu(anchor, function()
            local items = {}
            for _, option in ipairs(service:GetLootHelperModeOptions() or {}) do
                items[#items + 1] = { text = option.label, value = option.id, func = function(value) service:UpdateLootHelperSettings({ mode = value }); onRefresh() end }
            end
            return items
        end)
    end)
    modeButton.Text:SetText(settings.mode == "all-tradeable" and "All Tradeable Loot" or "BIS Only")

    local detectionButton = createSelectButton(dialog, { x = 248, y = -88, w = 316, h = 24 }, "Detection", function(anchor)
        showDropdownMenu(anchor, function()
            local items = {}
            for _, option in ipairs(service:GetLootHelperDetectionOptions() or {}) do
                items[#items + 1] = { text = option.label, value = option.id, func = function(value) service:UpdateLootHelperSettings({ detectionMode = value }); onRefresh() end }
            end
            return items
        end)
    end)
    detectionButton.Text:SetText(settings.detectionMode == "broad" and "Broad Best-Effort Detection" or "Strict Tradable Detection")

    local optionsPanel = createPanel(dialog, { x = 14, y = -126, w = 552, h = 168 }, "Popup Options", nil)
    createCheckbox(optionsPanel, { x = 12, y = -48, w = 520, h = 20 }, "Auto open the BIS Drops popup when new rows arrive", settings.autoOpenPopup and true or false, function(value) service:UpdateLootHelperSettings({ autoOpenPopup = value }); onRefresh() end)
    createCheckbox(optionsPanel, { x = 12, y = -74, w = 520, h = 20 }, "Play a sound when the BIS Drops popup opens", settings.playPopupSound and true or false, function(value) service:UpdateLootHelperSettings({ playPopupSound = value }); onRefresh() end)
    createCheckbox(optionsPanel, { x = 12, y = -100, w = 520, h = 20 }, "Whisper the other player when GH3 sees a successful trade close", settings.whisperTradeCompletion and true or false, function(value) service:UpdateLootHelperSettings({ whisperTradeCompletion = value }); onRefresh() end)
    createCheckbox(optionsPanel, { x = 12, y = -126, w = 520, h = 20 }, "Keep active rows until dismissed instead of auto-expiring them", settings.keepRowsUntilDismissed and true or false, function(value) service:UpdateLootHelperSettings({ keepRowsUntilDismissed = value }); onRefresh() end)

    local queuePanel = createPanel(dialog, { x = 14, y = -308, w = 552, h = 94 }, "Queue", nil)
    local queueSummary = queuePanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    queueSummary:SetPoint("TOPLEFT", queuePanel, "TOPLEFT", 14, -48)
    queueSummary:SetWidth(390)
    queueSummary:SetJustifyH("LEFT")
    queueSummary:SetText(string.format("%d active review row(s). Mode: %s. Detection: %s.", #activeRows, settings.mode == "all-tradeable" and "All Tradeable Loot" or "BIS Only", settings.detectionMode == "broad" and "Broad Best-Effort" or "Strict Tradable"))
    queueSummary:SetTextColor(0.82, 0.78, 0.68, 1)
    createButton(queuePanel, { x = 392, y = -40, w = 146, h = 28 }, "Open Active Drops", function() page:ShowDropReviewDialog() end, false)

    local historyPanel = createPanel(dialog, { x = 14, y = -416, w = 552, h = 152 }, "Loot History", nil)
    local historyPolicy = historyPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    historyPolicy:SetPoint("TOPLEFT", historyPanel, "TOPLEFT", 14, -48)
    historyPolicy:SetWidth(520)
    historyPolicy:SetJustifyH("LEFT")
    historyPolicy:SetText(service:GetLootHistoryPolicyText())
    historyPolicy:SetTextColor(0.82, 0.78, 0.68, 1)

    for index = 1, math.min(#historyRows, 4) do
        local entry = historyRows[index]
        local line = historyPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        line:SetPoint("TOPLEFT", historyPanel, "TOPLEFT", 14, -72 - ((index - 1) * 16))
        line:SetWidth(520)
        line:SetJustifyH("LEFT")
        line:SetText(string.format("%s | %s | %s", entry.at and date("%m/%d %H:%M", entry.at) or "--", trimString(entry.outcome) ~= "" and trimString(entry.outcome) or "event", trimString(entry.itemLink) ~= "" and trimString(entry.itemLink) or (trimString(entry.itemName) ~= "" and trimString(entry.itemName) or "Item")))
        line:SetTextColor(0.96, 0.93, 0.84, 1)
    end

    createButton(dialog, { x = 434, y = -572, w = 132, h = 34 }, "Close", onClose, false)
    return overlay
end

clearChildren = function(parent)
    if not parent then
        return
    end

    for _, child in ipairs({ parent:GetChildren() }) do
        child:Hide()
        child:SetParent(nil)
    end
end

local function splitDisplayName(fullName)
    local name = trimString(fullName):match("^[^-]+")
    return trimString(name) ~= "" and name or trimString(fullName)
end

local function attachTooltip(widget, titleText, bodyText)
    if not widget then
        return
    end

    widget:SetScript("OnEnter", function(control)
        GameTooltip:SetOwner(control, "ANCHOR_RIGHT")
        GameTooltip:SetText(titleText or "GH3", 0.93, 0.84, 0.63, 1, true)

        if trimString(bodyText) ~= "" then
            GameTooltip:AddLine(tostring(bodyText), 0.82, 0.82, 0.82, true)
        end

        GameTooltip:Show()
    end)
    widget:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)
end

local function attachItemTooltip(widget, rowData, extraText)
    if not widget then
        return
    end

    widget:SetScript("OnEnter", function(control)
        GameTooltip:SetOwner(control, "ANCHOR_RIGHT")

        local hyperlink = trimString(rowData and rowData.itemLink):match("|H([^|]+)|h")
        if hyperlink ~= "" then
            GameTooltip:SetHyperlink(hyperlink)
        else
            GameTooltip:SetText(trimString(rowData and rowData.itemName) ~= "" and rowData.itemName or "Item", 0.93, 0.84, 0.63, 1, true)
        end

        if trimString(extraText) ~= "" then
            GameTooltip:AddLine(tostring(extraText), 0.82, 0.82, 0.82, true)
        end

        GameTooltip:Show()
    end)
    widget:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)
end

local function buildBadgeStyle(kind, targetId)
    if kind == "offer" then
        if trimString(targetId) ~= "" then
            return "CLAIMED", BUTTON_ACTIVE_ART_KEY, { 0.74, 0.88, 1, 1 }
        end

        return "OFFER", BUTTON_ART_KEY, { 0.98, 0.90, 0.62, 1 }
    end

    if kind == "self-offer" then
        return "OFFER", BUTTON_ART_KEY, { 0.98, 0.90, 0.62, 1 }
    end

    return "YOURBIS", "MyToonsRowBackground", { 0.80, 0.98, 0.80, 1 }
end

local function buildStatusBadgeStyle(rowData)
    if rowData and rowData.leftGroup then
        return "LEFT", BUTTON_ART_KEY, { 0.98, 0.84, 0.80, 1 }
    end

    local highestRoll = 0
    for _, rollValue in pairs(type(rowData and rowData.rolls) == "table" and rowData.rolls or {}) do
        highestRoll = math.max(highestRoll, tonumber(rollValue) or 0)
    end

    if highestRoll > 0 then
        return "ROLL", BUTTON_ACTIVE_ART_KEY, { 0.94, 0.80, 1, 1 }
    end

    return nil, nil, nil
end

local function buildInterestSummary(rowData)
    local claimantNames = {}
    local rollEntries = {}
    local highestRoll = 0

    for _, rollValue in pairs(type(rowData and rowData.rolls) == "table" and rowData.rolls or {}) do
        highestRoll = math.max(highestRoll, tonumber(rollValue) or 0)
    end

    for claimantId in pairs(type(rowData and rowData.claimantIds) == "table" and rowData.claimantIds or {}) do
        claimantNames[#claimantNames + 1] = splitDisplayName(claimantId)
    end

    table.sort(claimantNames, function(left, right)
        return string.lower(left) < string.lower(right)
    end)

    for rollerId, rollValue in pairs(type(rowData and rowData.rolls) == "table" and rowData.rolls or {}) do
        local resolvedRoll = tonumber(rollValue) or 0
        local prefix = resolvedRoll == highestRoll and highestRoll > 0 and "Winner " or ""
        rollEntries[#rollEntries + 1] = string.format("%s%s %d", prefix, splitDisplayName(rollerId), resolvedRoll)
    end

    table.sort(rollEntries, function(left, right)
        return string.lower(left) < string.lower(right)
    end)

    local parts = {}
    if #claimantNames > 0 then
        parts[#parts + 1] = "Need: " .. table.concat(claimantNames, ", ")
    end
    if #rollEntries > 0 then
        parts[#parts + 1] = "Rolls: " .. table.concat(rollEntries, ", ")
    end
    return table.concat(parts, " | ")
end

local function countTableKeys(values)
    local count = 0

    for _ in pairs(type(values) == "table" and values or {}) do
        count = count + 1
    end

    return count
end

local function buildCompactInterestSummary(rowData)
    local claimCount = countTableKeys(rowData and rowData.claimantIds)
    local highestRoll = 0
    local highestRollName = ""

    for rollerId, rollValue in pairs(type(rowData and rowData.rolls) == "table" and rowData.rolls or {}) do
        local resolvedRoll = tonumber(rollValue) or 0
        if resolvedRoll > highestRoll then
            highestRoll = resolvedRoll
            highestRollName = splitDisplayName(rollerId)
        end
    end

    local parts = {}
    if claimCount > 0 then
        parts[#parts + 1] = string.format("%d need", claimCount)
    end
    if highestRoll > 0 then
        parts[#parts + 1] = string.format("Win %s %d", highestRollName ~= "" and highestRollName or "?", highestRoll)
    end
    return table.concat(parts, " | ")
end

local function listDropReviewRows(service)
    local settings = service and service.GetLootHelperSettings and service:GetLootHelperSettings() or {}
    local rows = {}
    local allRows = service and service.ListActiveDropReviewRows and service:ListActiveDropReviewRows() or {}

    for _, rowData in ipairs(allRows) do
        if settings.reviewFilter == "all" or trimString(rowData.kind) == trimString(settings.reviewFilter) then
            rows[#rows + 1] = rowData
        end
    end

    table.sort(rows, function(left, right)
        if settings.reviewSort == "owner" then
            local leftOwner = string.lower(splitDisplayName(left.ownerId or ""))
            local rightOwner = string.lower(splitDisplayName(right.ownerId or ""))
            if leftOwner == rightOwner then
                return (tonumber(left.createdAt) or 0) > (tonumber(right.createdAt) or 0)
            end
            return leftOwner < rightOwner
        end

        if settings.reviewSort == "ilevel" then
            local leftLevel = tonumber(left.itemLevel) or 0
            local rightLevel = tonumber(right.itemLevel) or 0
            if leftLevel == rightLevel then
                return (tonumber(left.createdAt) or 0) > (tonumber(right.createdAt) or 0)
            end
            return leftLevel > rightLevel
        end

        return (tonumber(left.createdAt) or 0) > (tonumber(right.createdAt) or 0)
    end)

    return rows, allRows, settings
end

local function playDropReviewOpenSound(service)
    if not service or service:GetLootHelperSettings().playPopupSound == false or not PlaySound or type(SOUNDKIT) ~= "table" then
        return
    end

    for _, key in ipairs({ "IG_MAINMENU_OPEN", "UI_QUEST_LOG_OPEN", "IG_CHARACTER_INFO_OPEN" }) do
        local soundKitId = SOUNDKIT[key]
        if soundKitId then
            PlaySound(soundKitId)
            return
        end
    end
end

local function clampPopupOffsets(dialog, offsetX, offsetY)
    local resolvedX = tonumber(offsetX) or 0
    local resolvedY = tonumber(offsetY) or 0
    local dialogWidth = dialog and dialog.GetWidth and dialog:GetWidth() or 0
    local parentWidth = UIParent and UIParent.GetWidth and UIParent:GetWidth() or 0
    local parentHeight = UIParent and UIParent.GetHeight and UIParent:GetHeight() or 0
    local visibleMargin = 40

    if dialogWidth > 0 and parentWidth > 0 then
        local minX = math.min(0, visibleMargin - dialogWidth)
        local maxX = math.max(0, parentWidth - visibleMargin)
        resolvedX = math.max(minX, math.min(maxX, resolvedX))
    end

    if parentHeight > 0 then
        local minY = math.min(0, visibleMargin - parentHeight)
        resolvedY = math.max(minY, math.min(0, resolvedY))
    end

    return math.floor(resolvedX), math.floor(resolvedY)
end

local function savePopupPosition(service, dialog)
    if not (service and dialog) then
        return
    end

    local settings = service:GetLootHelperSettings()
    local compact = dialog._compact and true or false
    local left = dialog.GetLeft and dialog:GetLeft() or nil
    local top = dialog.GetTop and dialog:GetTop() or nil
    local parentLeft = UIParent and UIParent.GetLeft and UIParent:GetLeft() or 0
    local parentTop = UIParent and UIParent.GetTop and UIParent:GetTop() or 0

    if not left or not top then
        return
    end

    local offsetX = math.floor(left - parentLeft)
    local offsetY = math.floor(top - parentTop)
    dialog._sessionPopupOffsets = dialog._sessionPopupOffsets or {}
    dialog._sessionPopupOffsets[compact and "compact" or "full"] = { x = offsetX, y = offsetY }

    if not settings.pinPopupPosition then
        return
    end

    if compact then
        service:UpdateLootHelperSettings({
            compactPopupOffsetX = offsetX,
            compactPopupOffsetY = offsetY,
            compactPopupPositionSaved = true,
        })
    else
        service:UpdateLootHelperSettings({
            popupOffsetX = offsetX,
            popupOffsetY = offsetY,
            popupPositionSaved = true,
        })
    end
end

local function decorateDropReviewDialog(dialog)
    dialog._gh3DropReviewArt = dialog._gh3DropReviewArt or {}

    local function ensureTexture(key, layer, textureKey)
        if not dialog._gh3DropReviewArt[key] then
            dialog._gh3DropReviewArt[key] = dialog:CreateTexture(nil, layer or "ARTWORK")
        end

        local texture = dialog._gh3DropReviewArt[key]
        texture:ClearAllPoints()
        applyArtTexture(texture, textureKey, { 0.08, 0.09, 0.12, 0.96 })
        texture:SetVertexColor(1, 1, 1, 0.98)
        texture:Show()
        return texture
    end

    ensureTexture("background", "BACKGROUND", PANEL_ART_KEY):SetAllPoints(dialog)

    local header = ensureTexture("header", "ARTWORK", PANEL_HEADER_KEY)
    header:SetPoint("TOPLEFT", dialog, "TOPLEFT", 18, -8)
    header:SetPoint("TOPRIGHT", dialog, "TOPRIGHT", -18, -8)
    header:SetHeight(42)

    local footer = ensureTexture("footer", "ARTWORK", "HomeFooterBackground")
    footer:SetPoint("BOTTOMLEFT", dialog, "BOTTOMLEFT", 18, 8)
    footer:SetPoint("BOTTOMRIGHT", dialog, "BOTTOMRIGHT", -18, 8)
    footer:SetHeight(42)

    local ornament = ensureTexture("ornament", "OVERLAY", "ShellHeaderMain")
    ornament:SetPoint("TOP", dialog, "TOP", 0, -2)
    ornament:SetSize(260, 18)

    if not dialog._gh3DropReviewArt.icon then
        dialog._gh3DropReviewArt.icon = dialog:CreateTexture(nil, "OVERLAY")
    end
    dialog._gh3DropReviewArt.icon:SetTexture("Interface\\AddOns\\GH3\\Media\\Icons\\Icon_BIS-l.png")
    dialog._gh3DropReviewArt.icon:SetPoint("TOPLEFT", dialog, "TOPLEFT", 22, -14)
    dialog._gh3DropReviewArt.icon:SetSize(42, 42)
    dialog._gh3DropReviewArt.icon:Show()
end

local function applyDropReviewDialogTransparency(dialog)
    if not dialog then
        return
    end

    local alpha = getConfiguredBISPopupTransparencyAlpha()

    for _, texture in pairs(dialog._gh3DropReviewArt or {}) do
        if texture and texture.SetAlpha then
            texture:SetAlpha(alpha)
        end
    end

    if dialog.headerRow and dialog.headerRow.Background and dialog.headerRow.Background.SetAlpha then
        dialog.headerRow.Background:SetAlpha(alpha)
    end
end

function page:IsDropReviewDialogShown()
    return self._dropReviewFrame and self._dropReviewFrame:IsShown() or false
end

function page:HideDropReviewDialog()
    if self._dropReviewFrame then
        self._dropReviewFrame:Hide()
    end
end

function page:ApplyConfiguredPopupTransparency()
    applyDropReviewDialogTransparency(self._dropReviewFrame)
end

local function buildBlankGearSelectorRows(service)
    local rows = {}

    for _, slot in ipairs(service and service.GetSlotOptions and service:GetSlotOptions() or {}) do
        rows[#rows + 1] = {
            itemId = "",
            itemName = "",
            notes = "",
            priority = "bis",
            slotKey = slot.key,
            slotLabel = slot.label,
            sourceName = "",
            sourceTarget = "",
            sourceType = "all",
        }
    end

    return rows
end

function page:OpenGearSelector(titleText, toonId)
    local service = addon.BISService
    if not service then
        return false
    end

    local selectedSpecId = service:GetSelectedSpecId(toonId)
    self.State = self.State or {}
    self.State.gearSelector = {
        title = titleText or "BIS Gear Select",
        toonId = toonId,
        selectedSpecId = selectedSpecId,
        selectedLootSpecId = service:GetLootSpecId(toonId),
        rowsData = service:ListGearEditorRows(toonId, selectedSpecId) or {},
    }

    if #self.State.gearSelector.rowsData <= 0 then
        self.State.gearSelector.rowsData = buildBlankGearSelectorRows(service)
    end

    if self._gearSelectorFrame and self._gearSelectorFrame.Hide then
        self._gearSelectorFrame:Hide()
    end

    if isBISPageActive() and self._parentFrame then
        refreshPage(self._parentFrame, self._snapshot)
    end

    return true
end

function page:ShowDropReviewDialog()
    local service = addon.BISService
    if not service then
        return false
    end

    if InCombatLockdown and InCombatLockdown() then
        self._pendingDropReviewAfterCombat = true
        return false
    end

    self._pendingDropReviewAfterCombat = false

    local frame = self._dropReviewFrame
    if not frame then
        frame = CreateFrame("Frame", "GH3NewUIBISDropReviewFrame", UIParent, BackdropTemplateMixin and "BackdropTemplate" or nil)
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(220)
        frame:SetToplevel(true)
        frame:SetClampedToScreen(false)
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", function(dialog)
            dialog:StopMovingOrSizing()
            savePopupPosition(service, dialog)
        end)
        frame:SetScript("OnMouseUp", function(dialog, button)
            if button ~= "RightButton" or service:GetLootHelperSettings().compactPopup ~= true then
                return
            end

            service:UpdateLootHelperSettings({ compactPopup = false })
            dialog:ApplyLayout()
            dialog:RenderRows()
        end)

        if frame.SetBackdrop then
            frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
            frame:SetBackdropColor(0, 0, 0, 0)
            frame:SetBackdropBorderColor(0, 0, 0, 0)
        end

        decorateDropReviewDialog(frame)
        applyDropReviewDialogTransparency(frame)

        frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        frame.title:SetFont("Fonts\\MORPHEUS.ttf", 22, "OUTLINE")
        frame.title:SetTextColor(0.93, 0.84, 0.63, 1)
        frame.title:SetText("GH3 BIS Drops")

        frame.helperText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        frame.helperText:SetWidth(560)
        frame.helperText:SetJustifyH("LEFT")
        frame.helperText:SetText("W whisper, C claim, O offer, R roll, T trade, D dismiss.")
        frame.helperText:SetTextColor(0.88, 0.84, 0.76, 1)

        frame.sortButton = createSelectButton(frame, { x = 0, y = 0, w = 140, h = 32 }, "Newest", function(anchor)
            showDropdownMenu(anchor, function()
                local items = {}
                local settings = service:GetLootHelperSettings()
                for _, option in ipairs(service:GetLootReviewSortOptions() or {}) do
                    items[#items + 1] = {
                        text = option.label,
                        value = option.id,
                        checked = settings.reviewSort == option.id,
                        func = function(value)
                            service:UpdateLootHelperSettings({ reviewSort = value })
                            frame.sortButton.Text:SetText(option.label)
                            frame:RenderRows()
                        end,
                    }
                end
                return items
            end)
        end)
        frame.sortButton.Text:SetText(service.GetLootReviewSortLabel and service:GetLootReviewSortLabel() or "Newest")

        frame.filterButton = createSelectButton(frame, { x = 0, y = 0, w = 160, h = 32 }, "All Rows", function(anchor)
            showDropdownMenu(anchor, function()
                local items = {}
                local settings = service:GetLootHelperSettings()
                for _, option in ipairs(service:GetLootReviewFilterOptions() or {}) do
                    items[#items + 1] = {
                        text = option.label,
                        value = option.id,
                        checked = settings.reviewFilter == option.id,
                        func = function(value)
                            service:UpdateLootHelperSettings({ reviewFilter = value })
                            frame.filterButton.Text:SetText(option.label)
                            frame:RenderRows()
                        end,
                    }
                end
                return items
            end)
        end)
        frame.filterButton.Text:SetText(service.GetLootReviewFilterLabel and service:GetLootReviewFilterLabel() or "All Rows")

        frame.offerMenuButton = createButton(frame, { x = 0, y = 0, w = 44, h = 32 }, "O", nil, false)
        attachTooltip(frame.offerMenuButton, "Offer", "Choose one of your active tradable loot rows and GH3 will offer it to the current party or raid.")

        frame.tradeMenuButton = createButton(frame, { x = 0, y = 0, w = 44, h = 32 }, "T", nil, false)
        attachTooltip(frame.tradeMenuButton, "Trade", "Choose any current party or raid member from a dropdown and GH3 will try to open trade immediately.")

        frame.dismissAllButton = createButton(frame, { x = 0, y = 0, w = 44, h = 32 }, "D", function()
            service:ClearDropReviewRows()
            frame:Hide()
        end, false)
        frame.dismissAllButton.Background:SetVertexColor(0.94, 0.70, 0.70, 1)
        attachTooltip(frame.dismissAllButton, "Dismiss All", "Clear all active BIS Drops rows and close the popup.")

        frame.pinButton = createButton(frame, { x = 0, y = 0, w = 44, h = 32 }, "P", function()
            local settings = service:GetLootHelperSettings()
            service:UpdateLootHelperSettings({ pinPopupPosition = settings.pinPopupPosition ~= true })
            savePopupPosition(service, frame)
            frame:ApplyLayout()
        end, false)
        attachTooltip(frame.pinButton, "Pin Position", "Remember the popup's dragged position instead of reopening in the default layout slot.")

        frame.compactButton = createTextureButton(frame, 30, "ShellMinimizeButton", "ShellMinimizeButton", function()
            service:UpdateLootHelperSettings({ compactPopup = not service:GetLootHelperSettings().compactPopup })
            frame:ApplyLayout()
            frame:RenderRows()
        end)
        attachTooltip(frame.compactButton, "Compact Window", "Collapse the BIS popup to a small draggable summary strip.")

        frame.closeButton = createTextureButton(frame, 34, "ShellCloseButton", "ShellCloseButtonHover", function()
            frame:Hide()
        end)

        frame.headerRow = createSurface(frame, { x = 24, y = -116, w = 1180, h = 34 }, PANEL_HEADER_KEY)
        frame.ownerHeader = frame.headerRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        frame.ownerHeader:SetTextColor(0.98, 0.90, 0.64, 1)
        frame.ownerHeader:SetText("Owner")
        frame.itemHeader = frame.headerRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        frame.itemHeader:SetTextColor(0.98, 0.90, 0.64, 1)
        frame.itemHeader:SetText("Item")
        frame.levelHeader = frame.headerRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        frame.levelHeader:SetTextColor(0.98, 0.90, 0.64, 1)
        frame.levelHeader:SetText("iLVL")
        frame.actionsHeader = frame.headerRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        frame.actionsHeader:SetTextColor(0.98, 0.90, 0.64, 1)
        frame.actionsHeader:SetText("Actions")

        frame.scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
        frame.scrollContent = CreateFrame("Frame", nil, frame.scrollFrame)
        frame.scrollContent:SetSize(1180, 1)
        frame.scrollFrame:SetScrollChild(frame.scrollContent)
        enableMouseWheelScroll(frame.scrollFrame, 32)

        frame.countLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        frame.countLabel:SetTextColor(0.98, 0.82, 0.46, 1)

        frame.footerHint = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        frame.footerHint:SetText("Burst loots are batched before the popup opens.")
        frame.footerHint:SetTextColor(0.88, 0.84, 0.76, 1)

        frame.compactSummary = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        frame.compactSummary:SetTextColor(0.96, 0.90, 0.68, 1)
        frame.compactSummary:SetJustifyH("LEFT")

        frame.bottomCloseButton = createButton(frame, { x = 0, y = 0, w = 184, h = 42 }, "Close", function()
            frame:Hide()
        end, false)

        function frame:RefreshTradeTargets()
            local offerRows = service:ListOfferableDropReviewRows() or {}
            local targets = service:ListCurrentGroupTradeTargets() or {}

            self.offerMenuButton:SetEnabled(#offerRows > 0)
            self.offerMenuButton:SetAlpha(#offerRows > 0 and 1 or 0.55)
            self.tradeMenuButton:SetEnabled(#targets > 0)
            self.tradeMenuButton:SetAlpha(#targets > 0 and 1 or 0.55)
        end

        function frame:ApplyLayout()
            local settings = service:GetLootHelperSettings()
            local compact = settings.compactPopup and true or false
            local parentWidth = UIParent and UIParent.GetWidth and UIParent:GetWidth() or 1366
            local parentHeight = UIParent and UIParent.GetHeight and UIParent:GetHeight() or 768
            local width = compact and 310 or math.max(1040, math.min(1320, parentWidth - 40))
            local height = compact and 156 or math.max(540, math.min(732, parentHeight - 60))
            local headerWidth = width - 60
            local sessionOffsets = self._sessionPopupOffsets and self._sessionPopupOffsets[compact and "compact" or "full"] or nil
            local anchoredOffsetX = nil
            local anchoredOffsetY = nil

            self._compact = compact
            self:SetSize(width, height)
            self:ClearAllPoints()

            if settings.pinPopupPosition and compact and settings.compactPopupPositionSaved then
                anchoredOffsetX, anchoredOffsetY = clampPopupOffsets(self, settings.compactPopupOffsetX, settings.compactPopupOffsetY)
            elseif settings.pinPopupPosition and (not compact) and settings.popupPositionSaved then
                anchoredOffsetX, anchoredOffsetY = clampPopupOffsets(self, settings.popupOffsetX, settings.popupOffsetY)
            elseif sessionOffsets and sessionOffsets.x ~= nil and sessionOffsets.y ~= nil then
                anchoredOffsetX, anchoredOffsetY = clampPopupOffsets(self, sessionOffsets.x, sessionOffsets.y)
            end

            if anchoredOffsetX ~= nil and anchoredOffsetY ~= nil then
                self:SetPoint("TOPLEFT", UIParent, "TOPLEFT", anchoredOffsetX, anchoredOffsetY)
            else
                self:SetPoint(compact and "TOPRIGHT" or "CENTER", UIParent, compact and "TOPRIGHT" or "CENTER", compact and -24 or 0, compact and -160 or 0)
            end

            decorateDropReviewDialog(self)
            applyDropReviewDialogTransparency(self)

            self.title:SetText(compact and "GH3 BIS" or "GH3 BIS Drops")
            self.title:ClearAllPoints()
            self.title:SetPoint("TOPLEFT", self, "TOPLEFT", 76, compact and -18 or -20)
            self.helperText:ClearAllPoints()
            self.helperText:SetPoint("TOPLEFT", self, "TOPLEFT", 78, -54)
            self.helperText:SetShown(not compact)

            self.closeButton:ClearAllPoints()
            self.closeButton:SetPoint("TOPRIGHT", self, "TOPRIGHT", -22, -18)
            self.compactButton:ClearAllPoints()
            self.compactButton:SetPoint("RIGHT", self.closeButton, "LEFT", -8, 0)
            self.compactButton.NormalTextureKey = compact and "ShellMaximizeButton" or "ShellMinimizeButton"
            self.compactButton.HoverTextureKey = self.compactButton.NormalTextureKey
            applyArtTexture(self.compactButton.Texture, self.compactButton.NormalTextureKey, { 1, 1, 1, 1 })
            self.compactButton:SetShown(true)

            self.pinButton:ClearAllPoints()
            self.pinButton:SetPoint("RIGHT", self.compactButton, "LEFT", -8, 0)
            self.pinButton:SetShown(not compact)
            self.pinButton:SetAlpha(settings.pinPopupPosition and 1 or 0.55)

            self.dismissAllButton:ClearAllPoints()
            self.dismissAllButton:SetPoint("RIGHT", self.pinButton, "LEFT", -8, 0)
            self.dismissAllButton:SetShown(not compact)

            self.tradeMenuButton:ClearAllPoints()
            self.tradeMenuButton:SetPoint("RIGHT", self.dismissAllButton, "LEFT", -8, 0)
            self.tradeMenuButton:SetShown(not compact)

            self.offerMenuButton:ClearAllPoints()
            self.offerMenuButton:SetPoint("RIGHT", self.tradeMenuButton, "LEFT", -8, 0)
            self.offerMenuButton:SetShown(not compact)

            self.filterButton:ClearAllPoints()
            self.filterButton:SetPoint("RIGHT", self.offerMenuButton, "LEFT", -12, 0)
            self.filterButton:SetShown(not compact)

            self.sortButton:ClearAllPoints()
            self.sortButton:SetPoint("RIGHT", self.filterButton, "LEFT", -12, 0)
            self.sortButton:SetShown(not compact)

            self.headerRow:SetShown(not compact)
            self.headerRow:SetSize(headerWidth, 34)
            self.headerRow:ClearAllPoints()
            self.headerRow:SetPoint("TOPLEFT", self, "TOPLEFT", 26, -108)

            self.ownerHeader:ClearAllPoints()
            self.ownerHeader:SetPoint("LEFT", self.headerRow, "LEFT", 26, -1)
            self.itemHeader:ClearAllPoints()
            self.itemHeader:SetPoint("LEFT", self.headerRow, "LEFT", 324, -1)
            self.levelHeader:ClearAllPoints()
            self.levelHeader:SetPoint("LEFT", self.headerRow, "LEFT", headerWidth - 404, -1)
            self.actionsHeader:ClearAllPoints()
            self.actionsHeader:SetPoint("LEFT", self.headerRow, "LEFT", headerWidth - 224, -1)

            self.scrollFrame:ClearAllPoints()
            self.scrollFrame:SetPoint("TOPLEFT", self, "TOPLEFT", 26, -148)
            self.scrollFrame:SetPoint("BOTTOMRIGHT", self, "BOTTOMRIGHT", -32, 62)
            self.scrollFrame:SetShown(not compact)

            self.countLabel:ClearAllPoints()
            self.countLabel:SetPoint("BOTTOMLEFT", self, "BOTTOMLEFT", 32, 24)
            self.countLabel:SetShown(not compact)

            self.footerHint:ClearAllPoints()
            self.footerHint:SetPoint("BOTTOMRIGHT", self, "BOTTOMRIGHT", -210, 24)
            self.footerHint:SetShown(not compact)

            self.compactSummary:ClearAllPoints()
            self.compactSummary:SetPoint("BOTTOMLEFT", self, "BOTTOMLEFT", 24, 24)
            self.compactSummary:SetWidth(width - 88)
            self.compactSummary:SetShown(compact)

            self.bottomCloseButton:ClearAllPoints()
            self.bottomCloseButton:SetPoint("BOTTOMRIGHT", self, "BOTTOMRIGHT", -28, 14)
            self.bottomCloseButton:SetShown(not compact)

            self._layout = {
                compact = compact,
                headerWidth = headerWidth,
                ownerWidth = 248,
                itemLeft = 312,
                itemWidth = headerWidth - 540,
                levelLeft = headerWidth - 404,
                actionsLeft = headerWidth - 218,
            }
        end

        function frame:RenderRows()
            clearChildren(self.scrollContent)
            self.scrollContent:SetHeight(1)
            self:RefreshTradeTargets()

            self.sortButton.Text:SetText(service.GetLootReviewSortLabel and service:GetLootReviewSortLabel() or "Newest")
            self.filterButton.Text:SetText(service.GetLootReviewFilterLabel and service:GetLootReviewFilterLabel() or "All Rows")

            local rows, allRows, settings = listDropReviewRows(service)
            self.countLabel:SetText(string.format("Showing %d of %d active row(s)", #rows, #allRows))

            if self._compact then
                local offerRows = service.ListOfferableDropReviewRows and service:ListOfferableDropReviewRows() or {}
                local targets = service.ListCurrentGroupTradeTargets and service:ListCurrentGroupTradeTargets() or {}
                self.compactSummary:SetText(string.format("%d active | O %d | T %d | D %d | P %s", #rows, #offerRows, #targets, #rows, settings.pinPopupPosition and "on" or "off"))
                return
            end

            if #rows <= 0 then
                local empty = self.scrollContent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                empty:SetPoint("TOPLEFT", self.scrollContent, "TOPLEFT", 12, -12)
                empty:SetWidth((self._layout and self._layout.headerWidth or 1180) - 24)
                empty:SetJustifyH("LEFT")
                empty:SetText(settings.reviewFilter == "all" and "No active BIS drop reviews right now." or "No active rows match the current filter.")
                empty:SetTextColor(0.90, 0.88, 0.82, 1)
                self.scrollContent:SetHeight(72)
                return
            end

            local layout = self._layout or {}
            local yOffset = 0
            for _, rowData in ipairs(rows) do
                local interestSummary = buildInterestSummary(rowData)
                local compactInterestSummary = buildCompactInterestSummary(rowData)
                local rowHeight = 82
                local rowPanel = createSurface(self.scrollContent, { x = 0, y = -yOffset, w = layout.headerWidth or 1180, h = rowHeight }, "MyToonsRowBackground")

                local badgeText, badgeTextureKey, badgeTextColor = buildBadgeStyle(trimString(rowData.kind), rowData.targetId)
                local badge = createSurface(rowPanel, { x = 18, y = -14, w = 100, h = 28 }, badgeTextureKey or BUTTON_ART_KEY)
                local badgeLabel = badge:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                badgeLabel:SetPoint("CENTER", badge, "CENTER", 0, -1)
                badgeLabel:SetText(badgeText)
                badgeLabel:SetTextColor(unpack(badgeTextColor or { 0.98, 0.90, 0.64, 1 }))

                local statusText, statusTextureKey, statusTextColor = buildStatusBadgeStyle(rowData)
                if statusText then
                    local statusBadge = createSurface(rowPanel, { x = 126, y = -14, w = 82, h = 28 }, statusTextureKey or BUTTON_ACTIVE_ART_KEY)
                    local statusLabel = statusBadge:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                    statusLabel:SetPoint("CENTER", statusBadge, "CENTER", 0, -1)
                    statusLabel:SetText(statusText)
                    statusLabel:SetTextColor(unpack(statusTextColor or { 0.94, 0.84, 0.80, 1 }))
                end

                local ownerLabel = rowPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
                ownerLabel:SetPoint("TOPLEFT", rowPanel, "TOPLEFT", 18, -44)
                ownerLabel:SetWidth(layout.ownerWidth or 248)
                ownerLabel:SetJustifyH("LEFT")
                ownerLabel:SetText(splitDisplayName(rowData.ownerId))
                ownerLabel:SetTextColor(0.98, 0.86, 0.22, 1)

                local ownerMeta = rowPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                ownerMeta:SetPoint("TOPLEFT", ownerLabel, "BOTTOMLEFT", 0, -6)
                ownerMeta:SetWidth(layout.ownerWidth or 248)
                ownerMeta:SetJustifyH("LEFT")
                ownerMeta:SetText(
                    trimString(rowData.kind) == "self-offer" and (trimString(rowData.targetId) ~= "" and ("Trade to " .. splitDisplayName(rowData.targetId)) or "Choose trade target from T")
                    or (trimString(rowData.kind) == "offer" and trimString(rowData.targetId) ~= "" and ("To " .. splitDisplayName(rowData.targetId))
                    or (trimString(rowData.targetId) ~= "" and ("For " .. splitDisplayName(rowData.targetId)) or (rowData.leftGroup and "Left group" or "Loot holder"))))
                ownerMeta:SetTextColor(0.90, 0.88, 0.82, 1)

                local itemLabel = rowPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
                itemLabel:SetPoint("TOPLEFT", rowPanel, "TOPLEFT", layout.itemLeft or 312, -18)
                itemLabel:SetWidth(layout.itemWidth or 640)
                itemLabel:SetJustifyH("LEFT")
                itemLabel:SetWordWrap(true)
                itemLabel:SetText(trimString(rowData.itemLink) ~= "" and rowData.itemLink or rowData.itemName)

                local metaLabel = rowPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                metaLabel:SetPoint("TOPLEFT", itemLabel, "BOTTOMLEFT", 0, -6)
                metaLabel:SetWidth(layout.itemWidth or 640)
                metaLabel:SetJustifyH("LEFT")
                metaLabel:SetWordWrap(true)
                metaLabel:SetText(((trimString(rowData.metaText) ~= "" and rowData.metaText) or "BIS review") .. (rowData.leftGroup and " | Left group" or ""))
                metaLabel:SetTextColor(0.90, 0.88, 0.82, 1)

                local slotMeta = rowPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                slotMeta:SetPoint("TOPLEFT", metaLabel, "BOTTOMLEFT", 0, -4)
                slotMeta:SetWidth(layout.itemWidth or 640)
                slotMeta:SetJustifyH("LEFT")
                slotMeta:SetWordWrap(true)
                slotMeta:SetText(string.format("%s%s%s%s",
                    trimString(rowData.slotLabel) ~= "" and rowData.slotLabel or "Tracked slot",
                    trimString(rowData.lootSpecId) ~= "" and (" | " .. trimString(rowData.lootSpecId)) or "",
                    interestSummary ~= "" and (" | " .. interestSummary) or "",
                    compactInterestSummary ~= "" and interestSummary == "" and (" | " .. compactInterestSummary) or ""))
                slotMeta:SetTextColor(0.90, 0.88, 0.82, 1)

                local itemHotspot = CreateFrame("Button", nil, rowPanel)
                itemHotspot:SetPoint("TOPLEFT", rowPanel, "TOPLEFT", (layout.itemLeft or 312) - 2, -16)
                itemHotspot:SetSize((layout.itemWidth or 640) + 4, 22)
                attachItemTooltip(itemHotspot, rowData, trimString(rowData.metaText) ~= "" and rowData.metaText or interestSummary)

                local levelLabel = rowPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
                levelLabel:SetPoint("TOPLEFT", rowPanel, "TOPLEFT", layout.levelLeft or 776, -34)
                levelLabel:SetWidth(74)
                levelLabel:SetJustifyH("LEFT")
                levelLabel:SetText((tonumber(rowData.itemLevel) or 0) > 0 and tostring(tonumber(rowData.itemLevel) or 0) or "-")
                levelLabel:SetTextColor(0.98, 0.86, 0.22, 1)

                local actionLane = CreateFrame("Frame", nil, rowPanel)
                actionLane:SetPoint("TOPLEFT", rowPanel, "TOPLEFT", layout.actionsLeft or 962, -24)
                actionLane:SetSize(240, 32)

                local function runAction(actionKey)
                    if service:HandleDropReviewAction(rowData.id, actionKey) then
                        frame:RenderRows()
                    end
                end

                local nextActionX = 0
                local function addActionButton(label, actionKey, tooltipTitle, tooltipBody, tint)
                    local button = createButton(actionLane, { x = nextActionX, y = 0, w = 38, h = 30 }, label, function()
                        if actionKey == "T" and trimString(rowData.kind) == "self-offer" then
                            local targets = service:ListCurrentGroupTradeTargets() or {}
                            showDropdownMenu(button, function()
                                local items = {}
                                if #targets <= 0 then
                                    items[#items + 1] = { text = "No active group targets", value = "", disabled = true }
                                end
                                for _, targetId in ipairs(targets) do
                                    items[#items + 1] = {
                                        text = splitDisplayName(targetId),
                                        value = targetId,
                                        checked = trimString(rowData.targetId) == trimString(targetId),
                                        func = function(value)
                                            service:SetDropReviewRowTarget(rowData.id, value)
                                            runAction(actionKey)
                                        end,
                                    }
                                end
                                return items
                            end)
                            return
                        end

                        runAction(actionKey)
                    end, false)
                    if tint then
                        button.Background:SetVertexColor(unpack(tint))
                    end
                    attachTooltip(button, tooltipTitle, tooltipBody)
                    if actionKey == "T" and rowData.leftGroup then
                        button:Disable()
                        button:SetAlpha(0.45)
                        attachTooltip(button, "Trade Unavailable", "This row is marked Left group. Whisper can still work, but trade cannot start until they rejoin your current party or raid.")
                    end
                    nextActionX = nextActionX + 48
                end

                if rowData.canWhisper then
                    addActionButton("W", "W", "Whisper", "Ask the holder for the item with a GH3 whisper.", { 0.70, 1.00, 0.78, 1 })
                end
                if rowData.canClaim then
                    addActionButton("C", "C", "Claim", "Tell the owner you want the item and send a GH3 claim to their addon.", { 0.86, 0.78, 1, 1 })
                end
                if rowData.canOffer then
                    addActionButton("O", "O", "Offer", "Post a group chat message asking if anyone needs this item and share it to GH3 users.", { 0.98, 0.90, 0.62, 1 })
                end
                if rowData.canRoll then
                    addActionButton("R", "R", "Roll", "Submit a GH3 1-100 roll for this item, post the result to group chat, and sync it to GH3 popups.", { 0.94, 0.80, 1, 1 })
                end
                if rowData.canTrade then
                    addActionButton("T", "T", "Trade", trimString(rowData.kind) == "self-offer" and "Choose an active party or raid member from a dropdown, then start trade." or "Target the relevant player and attempt to open trade if they are nearby.", { 0.82, 0.88, 1, 1 })
                end
                addActionButton("D", "D", "Dismiss", "Remove this row from the review popup without sending chat or opening trade.", { 0.98, 0.78, 0.76, 1 })

                attachTooltip(rowPanel, trimString(rowData.itemName) ~= "" and rowData.itemName or "BIS Review", string.format("Owner: %s\nTarget: %s\n%s%s",
                    trimString(rowData.ownerId) ~= "" and splitDisplayName(rowData.ownerId) or "Unknown",
                    trimString(rowData.targetId) ~= "" and splitDisplayName(rowData.targetId) or "Unknown",
                    trimString(rowData.metaText) ~= "" and trimString(rowData.metaText) or "Tracked GH3 BIS review row.",
                    interestSummary ~= "" and ("\n" .. interestSummary) or ""))

                yOffset = yOffset + rowHeight + 8
            end

            self.scrollContent:SetHeight(math.max(80, yOffset))
        end

        frame.offerMenuButton:SetScript("OnClick", function(buttonSelf)
            local offerRows = service:ListOfferableDropReviewRows() or {}
            showDropdownMenu(buttonSelf, function()
                local items = {}
                if #offerRows <= 0 then
                    items[#items + 1] = { text = "No offerable loot rows", value = "", disabled = true }
                end
                for _, rowData in ipairs(offerRows) do
                    items[#items + 1] = {
                        text = string.format("%s%s",
                            trimString(rowData.itemName) ~= "" and trimString(rowData.itemName) or trimString(rowData.itemLink),
                            (tonumber(rowData.itemLevel) or 0) > 0 and (" (" .. tostring(tonumber(rowData.itemLevel) or 0) .. ")") or ""),
                        value = rowData.id,
                        func = function(value)
                            if service:HandleDropReviewAction(value, "O") then
                                frame:RenderRows()
                            end
                        end,
                    }
                end
                return items
            end)
        end)

        frame.tradeMenuButton:SetScript("OnClick", function(buttonSelf)
            local targets = service:ListCurrentGroupTradeTargets() or {}
            showDropdownMenu(buttonSelf, function()
                local items = {}
                if #targets <= 0 then
                    items[#items + 1] = { text = "No active group targets", value = "", disabled = true }
                end
                for _, targetId in ipairs(targets) do
                    items[#items + 1] = {
                        text = splitDisplayName(targetId),
                        value = targetId,
                        func = function(value)
                            service:StartTradeWithPlayer(value)
                        end,
                    }
                end
                return items
            end)
        end)

        frame:SetScript("OnShow", function(dialog)
            dialog._refreshElapsed = 0
            playDropReviewOpenSound(service)
            dialog:ApplyLayout()
            dialog:RenderRows()
        end)
        frame:SetScript("OnUpdate", function(dialog, elapsed)
            if InCombatLockdown and InCombatLockdown() then
                page._pendingDropReviewAfterCombat = true
                return
            end

            dialog._refreshElapsed = (tonumber(dialog._refreshElapsed) or 0) + (tonumber(elapsed) or 0)
            if dialog._refreshElapsed < 1 then
                return
            end

            dialog._refreshElapsed = 0
            dialog:RenderRows()
            if #service:ListActiveDropReviewRows() <= 0 then
                dialog:Hide()
            end
        end)

        self._dropReviewFrame = frame
    end

    frame:ApplyLayout()
    frame:RenderRows()
    frame:Show()
    return true
end

function page:ShowJournalItemPickerDialog(titleText, pickerState, onSelect)
    local service = addon.BISService
    if not service then
        return false
    end

    local frame = self._journalItemPickerFrame
    if not frame then
        frame = CreateFrame("Frame", "GH3NewUIBISJournalItemPickerFrame", UIParent, BackdropTemplateMixin and "BackdropTemplate" or nil)
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(225)
        frame:SetToplevel(true)
        frame:SetClampedToScreen(true)
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", function(dialog) dialog:StopMovingOrSizing() end)
        if frame.SetBackdrop then
            frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
            frame:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
            frame:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)
        end

        frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        frame.title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
        frame.title:SetFont("Fonts\\MORPHEUS.ttf", 18, "OUTLINE")
        frame.title:SetTextColor(0.93, 0.84, 0.63, 1)
        frame.helperText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        frame.helperText:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -40)
        frame.helperText:SetWidth(860)
        frame.helperText:SetJustifyH("LEFT")
        frame.helperText:SetText("Choose an item row, then GH3 will copy the item, source, boss, and track information back into the gear selector. PvP gear is entered by item ID or Wowhead URL from the main BIS editor because it is not listed in Encounter Journal.")
        frame.helperText:SetTextColor(0.82, 0.78, 0.68, 1)
        createButton(frame, { x = 772, y = -12, w = 112, h = 24 }, "Close", function() frame:Hide() end, false)
        frame.searchLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        frame.searchLabel:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -72)
        frame.searchLabel:SetText("Search")
        frame.searchLabel:SetTextColor(0.82, 0.78, 0.68, 1)
        local searchFrame, searchInput = createGearEditorInput(frame, { x = 70, y = -62, w = 220, h = 24 }, "", "LEFT")
        frame.searchFrame = searchFrame
        frame.searchInput = searchInput
        frame.resultsLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        frame.resultsLabel:SetPoint("LEFT", searchFrame, "RIGHT", 14, 0)
        frame.resultsLabel:SetTextColor(0.82, 0.78, 0.68, 1)
        frame.scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
        frame.scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -98)
        frame.scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -34, 52)
        frame.scrollContent = CreateFrame("Frame", nil, frame.scrollFrame)
        frame.scrollContent:SetSize(860, 100)
        frame.scrollFrame:SetScrollChild(frame.scrollContent)

        function frame:RenderOptions()
            clearChildren(self.scrollContent)

            local rows = service:ListJournalLootOptions(self.state.toonId, self.state.specId, self.state.slotKey, {
                currentSeasonOnly = true,
                sourceType = self.state.sourceType,
            })
            local searchText = lowerText(self.searchInput:GetText())
            local filteredRows = {}

            for _, row in ipairs(rows) do
                local haystack = lowerText(table.concat({ row.itemName, row.sourceName, row.sourceTarget, row.trackLabel, row.difficultyLabel }, " "))
                if searchText == "" or string.find(haystack, searchText, 1, true) then
                    filteredRows[#filteredRows + 1] = row
                end
            end

            self.resultsLabel:SetText(string.format("%d result%s", #filteredRows, #filteredRows == 1 and "" or "s"))

            if #filteredRows <= 0 then
                local emptyText = self.scrollContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                emptyText:SetPoint("TOPLEFT", self.scrollContent, "TOPLEFT", 0, 0)
                emptyText:SetWidth(840)
                emptyText:SetJustifyH("LEFT")
                emptyText:SetText(self.state.sourceType == "pvp" and "PvP gear is not listed in Encounter Journal. Set Source to PvP in the BIS editor row, then paste a Blizzard item ID or Wowhead URL there." or "No journal items match the current slot and source filter.")
                emptyText:SetTextColor(0.82, 0.78, 0.68, 1)
                self.scrollContent:SetSize(860, 120)
                return
            end

            self.scrollContent:SetSize(860, math.max(140, (#filteredRows * 32) + 8))

            for index, row in ipairs(filteredRows) do
                local rowButton = createButton(self.scrollContent, { x = 0, y = -((index - 1) * 32), w = 840, h = 28 }, cropText(row.itemName, 44), function()
                    if type(self.onSelect) == "function" then
                        self.onSelect(row)
                    end
                    self:Hide()
                end, false)
                rowButton.Text:SetPoint("TOPLEFT", rowButton, "TOPLEFT", 8, -4)
                rowButton.Text:SetPoint("RIGHT", rowButton, "RIGHT", -8, 0)
                rowButton.Text:SetJustifyH("LEFT")
                local meta = rowButton:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                meta:SetPoint("BOTTOMLEFT", rowButton, "BOTTOMLEFT", 8, 4)
                meta:SetPoint("RIGHT", rowButton, "RIGHT", -8, 4)
                meta:SetJustifyH("LEFT")
                meta:SetText(string.format("%s | %s | %s", trimString(row.trackLabel) ~= "" and row.trackLabel or "Track ?", trimString(row.sourceName) ~= "" and row.sourceName or row.sourceTypeLabel, trimString(row.sourceTarget) ~= "" and row.sourceTarget or "-"))
                meta:SetTextColor(0.78, 0.74, 0.66, 1)
            end
        end

        frame.searchInput:SetScript("OnTextChanged", function()
            frame:RenderOptions()
        end)
        self._journalItemPickerFrame = frame
    end

    frame.state = type(pickerState) == "table" and pickerState or {}
    frame.onSelect = onSelect
    frame:SetSize(900, 520)
    frame:ClearAllPoints()
    frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    frame.title:SetText(titleText or "Select BIS Item")
    frame.searchInput:SetText("")
    frame:RenderOptions()
    frame:Raise()
    frame:Show()
    return true
end

function page:ShowGearSelectorDialog(titleText, toonId)
    local service = addon.BISService
    if not service then
        return false
    end

    local function buildBlankRows()
        local rows = {}
        for _, slot in ipairs(service:GetSlotOptions() or {}) do
            rows[#rows + 1] = { itemId = "", itemName = "", notes = "", priority = "bis", slotKey = slot.key, slotLabel = slot.label, sourceName = "", sourceTarget = "", sourceType = "all" }
        end
        return rows
    end

    local frame = self._gearSelectorFrame
    if not frame then
        frame = CreateFrame("Frame", "GH3NewUIBISGearSelectorFrame", UIParent, BackdropTemplateMixin and "BackdropTemplate" or nil)
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(220)
        frame:SetToplevel(true)
        frame:SetClampedToScreen(true)
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", function(dialog) dialog:StopMovingOrSizing() end)
        if frame.SetBackdrop then
            frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
            frame:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
            frame:SetBackdropBorderColor(0.62, 0.48, 0.18, 0.85)
        end

        frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        frame.title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
        frame.title:SetFont("Fonts\\MORPHEUS.ttf", 18, "OUTLINE")
        frame.title:SetTextColor(0.93, 0.84, 0.63, 1)
        frame.helperText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        frame.helperText:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -40)
        frame.helperText:SetWidth(1088)
        frame.helperText:SetJustifyH("LEFT")
        frame.helperText:SetText("Pick journal items by slot, add alternates with +, or paste a Blizzard item ID or Wowhead URL. For PvP gear, set Source to PvP and use the item ID or Wowhead URL field because Blizzard does not expose PvP vendor stock through Encounter Journal.")
        frame.helperText:SetTextColor(0.82, 0.78, 0.68, 1)
        frame.specDropdown = createSelectButton(frame, { x = 16, y = -72, w = 188, h = 24 }, "Spec", nil)
        frame.lootSpecDropdown = createSelectButton(frame, { x = 224, y = -72, w = 188, h = 24 }, "Loot Spec", nil)
        frame.clearButton = createButton(frame, { x = 902, y = -72, w = 82, h = 24 }, "Clear All", function() frame.rowsData = buildBlankRows(); frame:RenderRows() end, false)
        frame.debugButton = createButton(frame, { x = 992, y = -72, w = 62, h = 24 }, "Debug", function() if addon.MainFrame and addon.MainFrame.ShowCsvExportDialog then addon.MainFrame:ShowCsvExportDialog("BIS Journal Debug", service:BuildSelectedEntryJournalDebugReport(frame.toonId)) end end, false)
        frame.header = CreateFrame("Frame", nil, frame)
        frame.header:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -110)
        frame.header:SetSize(1090, 18)
        for _, info in ipairs({
            { text = "Slot", x = 0, width = 60 },
            { text = "Journal Item", x = 68, width = 240 },
            { text = "ItemID / URL", x = 316, width = 130 },
            { text = "Source", x = 454, width = 88 },
            { text = "Instance", x = 550, width = 150 },
            { text = "Boss", x = 708, width = 140 },
            { text = "Notes", x = 856, width = 180 },
        }) do
            local label = frame.header:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            label:SetPoint("TOPLEFT", frame.header, "TOPLEFT", info.x, 0)
            label:SetWidth(info.width)
            label:SetJustifyH("LEFT")
            label:SetText(info.text)
            label:SetTextColor(0.82, 0.78, 0.68, 1)
        end
        frame.scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
        frame.scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -130)
        frame.scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -34, 52)
        frame.scrollContent = CreateFrame("Frame", nil, frame.scrollFrame)
        frame.scrollContent:SetSize(1088, 100)
        frame.scrollFrame:SetScrollChild(frame.scrollContent)
        enableMouseWheelScroll(frame.scrollFrame, 34)
        frame.saveButton = createButton(frame, { x = 565, y = -602, w = 120, h = 24 }, "Save", function()
            service:SetSelectedSpecId(frame.toonId, frame.selectedSpecId)
            service:SetLootSpecId(frame.toonId, frame.selectedLootSpecId)
            local _, savedCount = service:SaveGearEditorRows(frame.toonId, frame.selectedSpecId, frame.rowsData)
            addon:Print(string.format("Saved %d BIS target%s.", savedCount, savedCount == 1 and "" or "s"))
            frame:Hide()
            if isBISPageActive() and page._parentFrame then refreshPage(page._parentFrame, page._snapshot) end
        end, true)
        frame.cancelButton = createButton(frame, { x = 435, y = -602, w = 120, h = 24 }, "Cancel", function() frame:Hide() end, false)

        function frame:ApplyPreview(row, options)
            local resolvedOptions = type(options) == "table" and options or {}
            local journalSpecId = trimString(self.selectedLootSpecId) ~= "" and self.selectedLootSpecId or self.selectedSpecId
            local preview = service:BuildGearEntryPreview(self.toonId, journalSpecId, row.slotKey, { itemId = row.itemId, itemName = row.itemName, notes = row.notes, priority = row.priority, sourceName = row.sourceName, sourceTarget = row.sourceTarget, sourceType = row.sourceType, __options = { currentSeasonOnly = true, forceSourceAutofill = resolvedOptions.forceSourceAutofill and true or false } })
            if (tonumber(preview.itemId) or 0) > 0 then row.itemId = tostring(preview.itemId) end
            if trimString(preview.itemName) ~= "" then row.itemName = preview.itemName end
            if trimString(preview.sourceType) ~= "" and preview.sourceType ~= "all" then row.sourceType = preview.sourceType end
            if resolvedOptions.forceSourceAutofill or trimString(preview.sourceName) ~= "" then row.sourceName = trimString(preview.sourceName) end
            if resolvedOptions.forceSourceAutofill or trimString(preview.sourceTarget) ~= "" then row.sourceTarget = trimString(preview.sourceTarget) end
        end

        function frame:RenderRows()
            clearChildren(self.scrollContent)
            local rows = self.rowsData or {}
            local rowHeight = 34
            self.scrollContent:SetSize(1088, math.max(120, (#rows * rowHeight) + 8))
            for index, row in ipairs(rows) do
                local y = -((index - 1) * rowHeight)
                local rowTextColor = getGearRowTextColor(row)
                local rowFrame = createSurface(self.scrollContent, { x = 0, y = y, w = 1068, h = 30 }, "MyToonsRowBackground")
                local slotText = rowFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                slotText:SetPoint("LEFT", rowFrame, "LEFT", 0, 0)
                slotText:SetWidth(60)
                slotText:SetJustifyH("LEFT")
                slotText:SetText(row.slotLabel or row.slotKey or "Slot")
                slotText:SetTextColor(rowTextColor[1], rowTextColor[2], rowTextColor[3], rowTextColor[4] or 1)
                self:RenderRowControls(index, row, rowFrame, rowTextColor)
            end
        end

        function frame:RenderRowControls(index, row, rowFrame, rowTextColor)
            local itemButton = createButton(rowFrame, { x = 50, y = -1, w = 252, h = 22 }, trimString(row.itemName) ~= "" and cropText(row.itemName, 28) or "Select Item", function()
                page:ShowJournalItemPickerDialog(string.format("%s Items", row.slotLabel or "BIS"), { currentSeasonOnly = true, slotKey = row.slotKey, sourceType = row.sourceType, specId = trimString(self.selectedLootSpecId) ~= "" and self.selectedLootSpecId or self.selectedSpecId, toonId = self.toonId }, function(option)
                    row.itemId = (tonumber(option.itemId) or 0) > 0 and tostring(option.itemId) or ""
                    row.itemName = option.itemName or ""
                    row.sourceType = option.sourceType or row.sourceType
                    row.sourceName = option.sourceName or ""
                    row.sourceTarget = option.sourceTarget or ""
                    self:RenderRows()
                end)
            end, false)
            itemButton.Text:SetTextColor(rowTextColor[1], rowTextColor[2], rowTextColor[3], rowTextColor[4] or 1)
            local _, itemIdInput = createGearEditorInput(rowFrame, { x = 316, y = -1, w = 130, h = 22 }, row.itemId or "", "LEFT")
            itemIdInput:SetNumeric(false)
            itemIdInput:SetScript("OnEditFocusGained", function(edit) edit._gh3OriginalText = edit:GetText() end)
            itemIdInput:SetScript("OnTextChanged", function(edit) row.itemId = edit:GetText() end)
            itemIdInput:SetScript("OnEditFocusLost", function(edit) row.itemId = edit:GetText(); self:ApplyPreview(row, { forceSourceAutofill = trimString(edit._gh3OriginalText) ~= trimString(edit:GetText()) }); self:RenderRows() end)
            local sourceButton = createSelectButton(rowFrame, { x = 444, y = -1, w = 104, h = 22 }, service:GetSourceTypeLabel(row.sourceType), function(anchor)
                showDropdownMenu(anchor, function()
                    local items = {}
                    for _, option in ipairs(service:GetSourceTypeOptions() or {}) do
                        if option.id ~= "all" then items[#items + 1] = { text = option.label, value = option.id, checked = row.sourceType == option.id, func = function(value) row.sourceType = value; self:RenderRows() end } end
                    end
                    return items
                end)
            end)
            sourceButton.Text:SetTextColor(rowTextColor[1], rowTextColor[2], rowTextColor[3], rowTextColor[4] or 1)
            local _, sourceNameInput = createGearEditorInput(rowFrame, { x = 550, y = -1, w = 150, h = 22 }, row.sourceName or "", "LEFT")
            sourceNameInput:SetScript("OnTextChanged", function(edit) row.sourceName = edit:GetText() end)
            local _, sourceTargetInput = createGearEditorInput(rowFrame, { x = 708, y = -1, w = 140, h = 22 }, row.sourceTarget or "", "LEFT")
            sourceTargetInput:SetScript("OnTextChanged", function(edit) row.sourceTarget = edit:GetText() end)
            local _, notesInput = createGearEditorInput(rowFrame, { x = 856, y = -1, w = 180, h = 22 }, row.notes or "", "LEFT")
            notesInput:SetScript("OnTextChanged", function(edit) row.notes = edit:GetText() end)
            createButton(rowFrame, { x = 1042, y = -1, w = 22, h = 22 }, "+", function() table.insert(self.rowsData, index + 1, { itemId = "", itemName = "", notes = "", priority = row.priority or "bis", slotKey = row.slotKey, slotLabel = row.slotLabel, sourceName = "", sourceTarget = "", sourceType = row.sourceType ~= "" and row.sourceType or "all" }); self:RenderRows() end, false)
            createButton(rowFrame, { x = 1066, y = -1, w = 22, h = 22 }, "-", function() table.remove(self.rowsData, index); self:RenderRows() end, false)
        end

        function frame:RefreshSpecLabels()
            local specOptions = service:ListSpecOptions(self.toonId) or {}
            local specLabel = self.selectedSpecId or "Spec"
            local lootSpecLabel = self.selectedLootSpecId or "Loot Spec"
            for _, option in ipairs(specOptions) do
                if option.id == self.selectedSpecId then specLabel = option.label end
                if option.id == self.selectedLootSpecId then lootSpecLabel = option.label end
            end
            self.specDropdown.Text:SetText(specLabel)
            self.lootSpecDropdown.Text:SetText(lootSpecLabel)
        end
        self._gearSelectorFrame = frame
    end

    frame.toonId = toonId
    frame.selectedSpecId = service:GetSelectedSpecId(toonId)
    frame.selectedLootSpecId = service:GetLootSpecId(toonId)
    frame.rowsData = service:ListGearEditorRows(toonId, frame.selectedSpecId) or {}
    if #frame.rowsData <= 0 then frame.rowsData = buildBlankRows() end
    frame.specDropdown:SetScript("OnClick", function(anchor)
        showDropdownMenu(anchor, function()
            local items = {}
            for _, option in ipairs(service:ListSpecOptions(frame.toonId) or {}) do
                items[#items + 1] = { text = option.label, value = option.id, checked = frame.selectedSpecId == option.id, func = function(value) frame.selectedSpecId = value; service:SetSelectedSpecId(frame.toonId, value); frame.rowsData = service:ListGearEditorRows(frame.toonId, value) or buildBlankRows(); frame:RefreshSpecLabels(); frame:RenderRows() end }
            end
            return items
        end)
    end)
    frame.lootSpecDropdown:SetScript("OnClick", function(anchor)
        showDropdownMenu(anchor, function()
            local items = {}
            for _, option in ipairs(service:ListSpecOptions(frame.toonId) or {}) do
                items[#items + 1] = { text = option.label, value = option.id, checked = frame.selectedLootSpecId == option.id, func = function(value) frame.selectedLootSpecId = value; frame:RefreshSpecLabels() end }
            end
            return items
        end)
    end)
    frame:SetSize(1130, 640)
    frame:ClearAllPoints()
    frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    frame.title:SetText(titleText or "BIS Gear Select")
    frame:RefreshSpecLabels()
    frame:RenderRows()
    frame:Raise()
    frame:Show()
    return true
end

local function createInlineInput(parent, rect, initialText, onCommit)
    local frame = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    local skipNextFocusLostCommit = false
    local lastCommittedValue = trimString(initialText)
    ApplyRect(frame, parent, rect)
    if frame.SetBackdrop then
        frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
        frame:SetBackdropColor(0.06, 0.08, 0.11, 0.96)
        frame:SetBackdropBorderColor(0.44, 0.34, 0.16, 0.72)
    end

    local input = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
    if input.Left then input.Left:SetAlpha(0) end
    if input.Middle then input.Middle:SetAlpha(0) end
    if input.Right then input.Right:SetAlpha(0) end
    input:SetAutoFocus(false)
    input:SetFontObject("GameFontHighlightSmall")
    input:SetPoint("TOPLEFT", frame, "TOPLEFT", 6, -2)
    input:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -6, 2)
    input:SetTextInsets(0, 0, 0, 0)
    input:SetJustifyH("RIGHT")
    input:SetText(initialText or "")
    input:SetTextColor(0.96, 0.93, 0.84, 1)

    local function commit()
        local nextValue = trimString(input:GetText())
        if nextValue == lastCommittedValue then
            return
        end
        lastCommittedValue = nextValue
        frame.LastCommittedValue = lastCommittedValue
        if onCommit then
            onCommit(nextValue)
        end
    end

    input:SetScript("OnEditFocusLost", function(self)
        if skipNextFocusLostCommit then
            skipNextFocusLostCommit = false
            return
        end
        commit()
    end)
    input:SetScript("OnEnterPressed", function(self)
        commit()
        skipNextFocusLostCommit = true
        self:ClearFocus()
    end)

    frame.Input = input
    frame.LastCommittedValue = lastCommittedValue
    return frame
end

createGearEditorInput = function(parent, rect, initialText, justifyH)
    local frame = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    ApplyRect(frame, parent, rect)
    if frame.SetBackdrop then
        frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
        frame:SetBackdropColor(0.06, 0.08, 0.11, 0.96)
        frame:SetBackdropBorderColor(0.44, 0.34, 0.16, 0.72)
    end

    local input = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
    if input.Left then input.Left:SetAlpha(0) end
    if input.Middle then input.Middle:SetAlpha(0) end
    if input.Right then input.Right:SetAlpha(0) end
    input:SetAutoFocus(false)
    input:SetFontObject("GameFontHighlightSmall")
    input:SetPoint("TOPLEFT", frame, "TOPLEFT", 6, -2)
    input:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -6, 2)
    input:SetTextInsets(0, 0, 0, 0)
    input:SetJustifyH(justifyH or "LEFT")
    input:SetText(initialText or "")
    input:SetTextColor(0.96, 0.93, 0.84, 1)
    frame.Input = input
    return frame, input
end

getGearRowTextColor = function(row)
    local visual = BIS_ROW_VISUALS[type(row) == "table" and row.rowVisualState or nil] or BIS_ROW_VISUALS.missing
    return visual and visual.textColor or { 0.96, 0.82, 0.46, 1 }
end

local function createStatCard(parent, rect, row, onCommit)
    local card = createSurface(parent, rect, CARD_ART_KEY)
    local insetLeft, insetRight, insetTop, insetBottom = getArtInsets(CARD_ART_KEY, "MyToonsCard")
    local contentLeft = math.max(24, insetLeft - 4)
    local contentRight = math.max(24, insetRight - 4)
    local contentTop = math.max(8, insetTop - 2)
    local contentBottom = math.max(8, insetBottom - 2)
    local contentWidth = math.max(1, (tonumber(rect.w) or 1) - contentLeft - contentRight)
    local inputWidth = math.min(84, math.max(64, math.floor(contentWidth * 0.30)))
    local leftWidth = math.max(82, contentWidth - inputWidth - 18)
    local content = CreateFrame("Frame", nil, card)
    content:SetPoint("TOPLEFT", card, "TOPLEFT", contentLeft, -contentTop)
    content:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -contentRight, contentBottom)

    local title = card:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    title:SetPoint("TOPLEFT", content, "TOPLEFT", 0, 0)
    title:SetWidth(leftWidth)
    title:SetJustifyH("LEFT")
    title:SetWordWrap(false)
    title:SetText(row.label or "Stat")
    title:SetTextColor(0.96, 0.82, 0.46, 1)

    local current = card:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    current:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -2)
    current:SetWidth(leftWidth)
    current:SetJustifyH("LEFT")
    current:SetWordWrap(false)
    current:SetText(row.currentLabel or "-")
    current:SetTextColor(0.98, 0.82, 0.34, 1)

    local inputX = (tonumber(rect.w) or 1) - contentRight - inputWidth
    local inputY = -(contentTop + 22)
    local input = createInlineInput(card, { x = inputX, y = inputY, w = inputWidth, h = 20 }, row.targetValue and row.targetValue > 0 and tostring(row.targetLabel or "") or "", onCommit)

    local goal = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    goal:SetPoint("BOTTOM", input, "TOP", 0, 2)
    goal:SetJustifyH("CENTER")
    goal:SetText("Goal")
    goal:SetTextColor(0.84, 0.82, 0.76, 1)

    local delta = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    delta:SetPoint("BOTTOMLEFT", content, "BOTTOMLEFT", 0, 0)
    delta:SetPoint("RIGHT", input, "LEFT", -8, 0)
    delta:SetJustifyH("LEFT")
    delta:SetWordWrap(false)
    delta:SetText(string.format("Delta %s", row.deltaLabel or "-"))
    delta:SetTextColor(0.84, 0.82, 0.76, 1)

    card.Title = title
    card.Current = current
    card.Goal = goal
    card.Input = input
    card.Delta = delta
    return card
end

local function createTrackedToonButton(parent, rect, toon, isActive, onClick)
    local button = CreateFrame("Button", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    ApplyRect(button, parent, rect)
    local background = button:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(button)
    applyArtTexture(background, "MyToonsRowBackground", { 0.10, 0.11, 0.14, 0.95 })
    background:SetVertexColor(1, 1, 1, isActive and 1 or 0.94)
    if button.SetBackdrop then
        button:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
        button:SetBackdropColor(0, 0, 0, 0)
        button:SetBackdropBorderColor(isActive and 0.80 or 0.44, isActive and 0.62 or 0.34, isActive and 0.18 or 0.16, isActive and 0.95 or 0.58)
    end
    button:SetScript("OnClick", function()
        if onClick then
            onClick(button)
        end
    end)

    local member = addon.RosterService and (addon.RosterService:GetMyToonMember(toon.id) or addon.RosterService:GetMember(toon.id)) or nil
    local toonName = trimString(toon.fullName or toon.id or "Unknown")
    local insetLeft, insetRight, insetTop, insetBottom = getArtInsets("MyToonsRowBackground", "MyToonsButton")
    local contentLeft = math.max(14, insetLeft + 4)
    local contentRight = math.max(14, insetRight + 4)
    local content = CreateFrame("Frame", nil, button)
    content:SetPoint("TOPLEFT", button, "TOPLEFT", contentLeft, -math.max(4, insetTop - 1))
    content:SetPoint("BOTTOMRIGHT", button, "BOTTOMRIGHT", -contentRight, math.max(4, insetBottom - 1))

    local icon = button:CreateTexture(nil, "OVERLAY")
    icon:SetPoint("LEFT", content, "LEFT", 0, 0)
    icon:SetSize(18, 18)
    local classToken = trimString(member and member.classFile)
    if classToken ~= "" and CLASS_ICON_TCOORDS and CLASS_ICON_TCOORDS[string.upper(classToken)] then
        local coords = CLASS_ICON_TCOORDS[string.upper(classToken)]
        icon:SetTexture(CLASS_ICON_TEXTURE)
        icon:SetTexCoord(coords[1], coords[2], coords[3], coords[4])
    else
        local r, g, b = getClassColor(classToken)
        icon:SetColorTexture(r, g, b, 0.28)
    end

    local counts = button:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    counts:SetPoint("RIGHT", content, "RIGHT", 0, 0)
    counts:SetWidth(42)
    counts:SetJustifyH("RIGHT")
    counts:SetWordWrap(false)
    counts:SetText(string.format("%d/%d", tonumber(toon.collectedCount) or 0, tonumber(toon.totalSlots) or 0))
    counts:SetTextColor(0.96, 0.93, 0.84, 1)

    local name = button:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    name:SetPoint("LEFT", icon, "RIGHT", 8, 0)
    name:SetPoint("RIGHT", counts, "LEFT", -8, 0)
    name:SetJustifyH("LEFT")
    name:SetWordWrap(false)
    name:SetText(toonName ~= "" and toonName or "Unknown")
    local r, g, b = getClassColor(classToken)
    name:SetTextColor(r, g, b, 1)

    button.Background = background
    button.Content = content
    button.Icon = icon
    button.NameText = name
    button.CountText = counts
    return button
end

local function buildGearTableRows(service, selectedToonId, parent, snapshot)
    local rows = {}
    for _, row in ipairs(service:ListGearRows(selectedToonId) or {}) do
        local visual = BIS_ROW_VISUALS[row.rowVisualState or "missing"] or BIS_ROW_VISUALS.missing
        rows[#rows + 1] = {
            values = { row.slotLabel, row.itemName, row.sourceTypeLabel, row.instanceLabel, row.bossLabel, row.stateLabel },
            highlightColor = visual.borderColor,
            textColor = visual.borderColor,
            selectedTextColor = visual.selectedBorderColor or visual.borderColor,
            selected = service:GetSelectedSlotKey() == row.slotKey,
            tooltipTitle = row.slotLabel,
            tooltipLines = { trimString(row.notes) ~= "" and row.notes or "No notes for this slot." },
            onClick = function()
                service:SetSelectedSlotKey(row.slotKey)
                refreshPage(parent, snapshot)
            end,
        }
    end
    return rows
end

local function createGearTableHeaders(parent, columns)
    local headerBand = parent:CreateTexture(nil, "BACKGROUND")
    headerBand:SetAllPoints(parent)
    applyArtTexture(headerBand, PANEL_HEADER_KEY, { 0.08, 0.08, 0.10, 0.72 })
    headerBand:SetVertexColor(1, 1, 1, 0.98)

    local topRule = parent:CreateTexture(nil, "ARTWORK")
    topRule:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
    topRule:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, 0)
    topRule:SetHeight(1)
    topRule:SetColorTexture(0.58, 0.44, 0.16, 0.72)

    for index, column in ipairs(columns) do
        if index > 1 then
            local divider = parent:CreateTexture(nil, "ARTWORK")
            divider:SetPoint("TOPLEFT", parent, "TOPLEFT", column.x, -2)
            divider:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", column.x, 2)
            divider:SetWidth(1)
            divider:SetColorTexture(0.44, 0.34, 0.16, 0.42)
        end

        local text = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        local anchor = column.align == "CENTER" and "CENTER" or "LEFT"
        local offsetX = column.align == "CENTER" and math.floor(column.w / 2) or 8
        text:SetPoint(anchor, parent, "LEFT", column.x + offsetX, -1)
        text:SetWidth(column.w - (column.align == "CENTER" and 0 or 12))
        text:SetWordWrap(false)
        text:SetJustifyH(column.align)
        text:SetText(column.text)
        text:SetTextColor(0.96, 0.82, 0.46, 1)
    end
end

local function renderGearTable(parent, rows)
    local rowWidth = math.max(1, (tonumber(parent:GetWidth()) or 968) - 24)
    local columns = {
        { key = "Slot", text = "Slot", w = 86, align = "LEFT" },
        { key = "Item", text = "Item", w = 0, align = "LEFT" },
        { key = "Source", text = "Source", w = 92, align = "LEFT" },
        { key = "Instance", text = "Instance", w = 198, align = "LEFT" },
        { key = "Boss", text = "Boss", w = 180, align = "LEFT" },
        { key = "Status", text = "Status", w = 118, align = "CENTER" },
    }
    local rowHeight = 32

    local fixedWidth = 0
    for _, column in ipairs(columns) do
        if column.key ~= "Item" then
            fixedWidth = fixedWidth + column.w
        end
    end
    columns[2].w = math.max(180, rowWidth - fixedWidth)
    local cursorX = 0
    for _, column in ipairs(columns) do
        column.x = cursorX
        cursorX = cursorX + column.w
    end

    local headerRow = CreateFrame("Frame", nil, parent)
    headerRow:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
    headerRow:SetSize(rowWidth, 28)
    createGearTableHeaders(headerRow, columns)

    local rowsViewport = CreateFrame("ScrollFrame", nil, parent, "UIPanelScrollFrameTemplate")
    rowsViewport:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -34)
    rowsViewport:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -24, 0)

    local rowsHostHeight = math.max(rowsViewport:GetHeight(), (#rows > 0 and (#rows * rowHeight) or rowHeight))
    local rowsHost = CreateFrame("Frame", nil, rowsViewport)
    rowsHost:SetPoint("TOPLEFT", rowsViewport, "TOPLEFT", 0, 0)
    rowsHost:SetSize(rowWidth, rowsHostHeight)
    rowsViewport:SetScrollChild(rowsHost)
    enableMouseWheelScroll(rowsViewport, rowHeight)

    for index, rowData in ipairs(rows) do
        local row = CreateFrame("Button", nil, rowsHost, BackdropTemplateMixin and "BackdropTemplate" or nil)
        row:SetPoint("TOPLEFT", rowsHost, "TOPLEFT", 0, -((index - 1) * rowHeight))
        row:SetSize(rowWidth, rowHeight - 2)
        local resolvedTextColor = rowData.selected and (rowData.selectedTextColor or rowData.textColor) or rowData.textColor or rowData.highlightColor or { 0.96, 0.82, 0.46, 1 }
        if row.SetBackdrop then
            row:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
            row:SetBackdropColor(0.14, 0.17, 0.22, rowData.selected and 0.98 or 0.82)
            row:SetBackdropBorderColor(rowData.selected and 0.34 or 0.42, rowData.selected and 0.46 or 0.33, rowData.selected and 0.68 or 0.16, rowData.selected and 0.85 or 0.48)
        end

        local accent = row:CreateTexture(nil, "ARTWORK")
        accent:SetPoint("TOPLEFT", row, "TOPLEFT", 0, -1)
        accent:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 1)
        accent:SetWidth(3)
        accent:SetColorTexture((rowData.highlightColor or resolvedTextColor)[1], (rowData.highlightColor or resolvedTextColor)[2], (rowData.highlightColor or resolvedTextColor)[3], 0.95)

        for columnIndex, column in ipairs(columns) do
            local text = row:CreateFontString(nil, "OVERLAY", column.key == "Item" and "GameFontNormal" or "GameFontNormalSmall")
            local anchor = column.align == "CENTER" and "CENTER" or "LEFT"
            local offsetX = column.align == "CENTER" and math.floor(column.w / 2) or 8
            text:SetPoint(anchor, row, "LEFT", column.x + offsetX, 0)
            text:SetWidth(column.w - (column.align == "CENTER" and 0 or 12))
            text:SetJustifyH(column.align)
            text:SetWordWrap(false)
            text:SetText(rowData.values[columnIndex] or "-")
            text:SetTextColor(resolvedTextColor[1], resolvedTextColor[2], resolvedTextColor[3], resolvedTextColor[4] or 1)
        end

        row:SetScript("OnClick", rowData.onClick)
        row:SetScript("OnEnter", function(self)
            if not GameTooltip or trimString(rowData.tooltipTitle) == "" then
                return
            end
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:ClearLines()
            GameTooltip:AddLine(rowData.tooltipTitle, 0.96, 0.82, 0.46)
            for _, line in ipairs(rowData.tooltipLines or {}) do
                GameTooltip:AddLine(line, 0.96, 0.93, 0.84, true)
            end
            GameTooltip:Show()
        end)
        row:SetScript("OnLeave", function()
            if GameTooltip then
                GameTooltip:Hide()
            end
        end)
    end
end

refreshPage = function(parent, snapshot)
    if page._rootFrame then
        clearFrame(page._rootFrame)
        page._rootFrame = nil
    end
    return page:Render(parent, snapshot)
end

function page:Render(parent, snapshot)
    if not parent then
        return nil
    end

    local service = addon.BISService
    local layout = addon.NewUILayout and addon.NewUILayout.BIS or {
        Page = { x = 0, y = 0, w = 1380, h = 742 },
        Toolbar = { x = 20, y = -52, w = 980, h = 28 },
        Toons = { Panel = { x = 20, y = -92, w = 324, h = 590 } },
        Summary = { Panel = { x = 360, y = -92, w = 1000, h = 274 } },
        Gear = { Panel = { x = 360, y = -382, w = 1000, h = 300 } },
    }
    local root = CreateFrame("Frame", nil, parent)
    ApplyRect(root, parent, layout.Page or { x = 0, y = 0, w = tonumber(parent:GetWidth()) or 1380, h = tonumber(parent:GetHeight()) or 742 })
    page._rootFrame = root
    page._parentFrame = parent
    page._snapshot = snapshot
    installMainFrameBridge()

    if not service then
        return createPanel(root, { x = 20, y = -20, w = 918, h = 180 }, "BIS Disabled", "The BIS service is not loaded right now, so this view is temporarily unavailable.")
    end

    addon.RosterService:RefreshLivePlayerMember()
    service:EnsureCurrentInventoryScan()

    local toons = service:ListToons()
    local selectedToonId = service:GetSelectedToonId()
    local selectedSpecId = service:GetSelectedSpecId(selectedToonId)
    service:BackfillJournalDataForSpec(selectedToonId, selectedSpecId, { allowJournalBuild = false })

    local summary = service:GetSummary(selectedToonId)
    local gearRows = buildGearTableRows(service, selectedToonId, parent, snapshot)
    local specOptions = service:ListSpecOptions(selectedToonId)
    local sourceOptions = service:GetSourceTypeOptions()
    local ownershipOptions = service:GetOwnershipOptions()
    local collectionSortOptions = service:GetCollectionSortOptions()
    local statGoalSummary = service:GetStatGoalSummary(selectedToonId, selectedSpecId)

    local toolbar = CreateFrame("Frame", nil, root)
    ApplyRect(toolbar, root, layout.Toolbar or { x = 20, y = -52, w = 980, h = 28 })

    local specDropdown = createSelectButton(toolbar, { x = 0, y = 0, w = 164, h = 24 }, "Spec: " .. tostring(summary.specLabel or selectedSpecId or "Unknown"), function(anchor)
        showDropdownMenu(anchor, function()
            local items = {}
            for _, option in ipairs(specOptions or {}) do
                items[#items + 1] = {
                    text = option.label,
                    value = option.id,
                    func = function(value)
                        service:SetSelectedSpecId(selectedToonId, value)
                        refreshPage(parent, snapshot)
                    end,
                }
            end
            return items
        end)
    end)
    specDropdown.Text:SetText("Spec: " .. tostring(summary.specLabel or selectedSpecId or "Unknown"))
    createButton(toolbar, { x = 176, y = 0, w = 70, h = 24 }, "Loot", function()
        self.State.showLootHelper = true
        refreshPage(parent, snapshot)
    end, false)
    createButton(toolbar, { x = 256, y = 0, w = 72, h = 24 }, "Share", function()
        self.State.textDialog = {
            title = "BIS Share JSON",
            helpText = "Copy this JSON to your guild site or share it with another GH3 player.",
            text = service:BuildShareExport(selectedToonId, selectedSpecId),
            readOnly = true,
        }
        refreshPage(parent, snapshot)
    end, false)
    createButton(toolbar, { x = 338, y = 0, w = 78, h = 24 }, "Import", function()
        self.State.textDialog = {
            title = "BIS Import",
            helpText = "Paste a GH3 BIS share JSON payload or legacy BIS CSV rows.",
            text = "",
            acceptText = "Import",
        }
        refreshPage(parent, snapshot)
    end, false)
    createButton(toolbar, { x = 426, y = 0, w = 98, h = 24 }, "Gear Select", function() self:OpenGearSelector("BIS Gear Select", selectedToonId) end, false)

    local listRect = (layout.Toons and layout.Toons.Panel) or { x = 20, y = -92, w = 324, h = 590 }
    local listPanel = createPanel(root, listRect, "Tracked Toons", "Personal BIS lists are stored per toon and spec.")
    local listScrollFrame = CreateFrame("ScrollFrame", nil, listPanel, "UIPanelScrollFrameTemplate")
    listScrollFrame:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 12, -58)
    listScrollFrame:SetPoint("BOTTOMRIGHT", listPanel, "BOTTOMRIGHT", -24, 14)
    local listContent = CreateFrame("Frame", nil, listScrollFrame)
    listContent:SetPoint("TOPLEFT", listScrollFrame, "TOPLEFT", 0, 0)
    listContent:SetSize((tonumber(listRect.w) or 324) - 38, math.max(1, #toons * 34))
    listScrollFrame:SetScrollChild(listContent)
    enableMouseWheelScroll(listScrollFrame, 34)

    for index, toon in ipairs(toons) do
        createTrackedToonButton(listContent, { x = 0, y = -((index - 1) * 34), w = (tonumber(listRect.w) or 324) - 38, h = 34 }, toon, toon.id == selectedToonId, function()
            service:SetSelectedToonId(toon.id)
            refreshPage(parent, snapshot)
        end)
    end

    local summaryRect = (layout.Summary and layout.Summary.Panel) or { x = 360, y = -92, w = 1000, h = 274 }
    local summaryPanel = createPanel(root, summaryRect, summary.fullName or "BIS Summary", summary.metaLine or "Track one BIS list per toon and spec.")
    local summaryInnerWidth = (tonumber(summaryRect.w) or 1000) - 28
    local summaryCardGap = 8
    local summaryCardWidth = math.floor((summaryInnerWidth - (summaryCardGap * 4)) / 5)
    local statCardGap = 0
    local statCardWidth = math.floor((summaryInnerWidth - (statCardGap * 3)) / 4)

    createCard(summaryPanel, { x = 14, y = -58, w = summaryCardWidth, h = 82 }, "Spec", summary.specLabel or "Unknown", "Active BIS list", "HomeCardBackground", { centered = true })
    createCard(summaryPanel, { x = 14 + ((summaryCardWidth + summaryCardGap) * 1), y = -58, w = summaryCardWidth, h = 82 }, "Loot Spec", summary.lootSpecLabel or "Default", "Tracked drop context", "HomeMythicCardBackground", { centered = true })
    createCard(summaryPanel, { x = 14 + ((summaryCardWidth + summaryCardGap) * 2), y = -58, w = summaryCardWidth, h = 82 }, "Collected", tostring(summary.collectedCount or 0), "Owned BIS pieces", "HomeMembersCardBackground", { centered = true })
    createCard(summaryPanel, { x = 14 + ((summaryCardWidth + summaryCardGap) * 3), y = -58, w = summaryCardWidth, h = 82 }, "Missing", tostring(summary.missingCount or 0), "Still unowned", "HomeRaidCardBackground", { centered = true })
    createCard(summaryPanel, { x = 14 + ((summaryCardWidth + summaryCardGap) * 4), y = -58, w = summaryCardWidth, h = 82 }, "Vault", tostring(summary.vaultTargetCount or 0), "Vault-marked targets", "HomeNewsCardBackground", { centered = true })

    local statRows = statGoalSummary.rows or {}
    local statInputs = {}
    for index, row in ipairs(statRows) do
        local column = (index - 1) % 4
        local gridRow = math.floor((index - 1) / 4)
        local card = createStatCard(summaryPanel, { x = 14 + (column * (statCardWidth + statCardGap)), y = -150 - (gridRow * 60), w = statCardWidth, h = 58 }, row, function(nextValue)
            service:UpdateStatTargets(selectedToonId, selectedSpecId, { [row.key] = nextValue })
            refreshPage(parent, snapshot)
        end)
        statInputs[index] = card and card.Input and card.Input.Input or nil
    end

    for index, input in ipairs(statInputs) do
        if input then
            input:SetScript("OnTabPressed", function(self)
                local totalInputs = #statInputs
                if totalInputs == 0 then
                    return
                end

                local nextIndex = index + 1
                if IsShiftKeyDown and IsShiftKeyDown() then
                    nextIndex = index - 1
                end
                if nextIndex > totalInputs then
                    nextIndex = 1
                elseif nextIndex < 1 then
                    nextIndex = totalInputs
                end

                local currentValue = trimString(self:GetText())
                local didChange = currentValue ~= trimString(input:GetParent() and input:GetParent().LastCommittedValue)
                if didChange then
                    page._pendingStatGoalFocusIndex = nextIndex
                    self:ClearFocus()
                    return
                end

                self:ClearFocus()
                if statInputs[nextIndex] and statInputs[nextIndex].SetFocus then
                    statInputs[nextIndex]:SetFocus()
                    page._pendingStatGoalFocusIndex = nil
                end
            end)
        end
    end

    if page._pendingStatGoalFocusIndex and statInputs[page._pendingStatGoalFocusIndex] and statInputs[page._pendingStatGoalFocusIndex].SetFocus then
        statInputs[page._pendingStatGoalFocusIndex]:SetFocus()
        page._pendingStatGoalFocusIndex = nil
    end

    local gearRect = (layout.Gear and layout.Gear.Panel) or { x = 360, y = -382, w = 1000, h = 300 }
    local gearPanel = createPanel(root, gearRect, "Gear Table", "Pick a slot to review overall BIS progress. Use Gear Select for full target editing, journal-backed item selection, and alternate targets for each slot.")
    local sourceDropdown = createSelectButton(gearPanel, { x = 14, y = -58, w = 200, h = 24 }, service:GetSourceFilterLabel(), function(anchor)
        showDropdownMenu(anchor, function()
            local items = {}
            for _, option in ipairs(sourceOptions or {}) do
                items[#items + 1] = { text = option.label, value = option.id, func = function(value) service:SetSourceFilter(value); refreshPage(parent, snapshot) end }
            end
            return items
        end)
    end)
    sourceDropdown.Text:SetText(service:GetSourceFilterLabel())
    local ownershipDropdown = createSelectButton(gearPanel, { x = 226, y = -58, w = 200, h = 24 }, service:GetOwnershipFilterLabel(), function(anchor)
        showDropdownMenu(anchor, function()
            local items = {}
            for _, option in ipairs(ownershipOptions or {}) do
                items[#items + 1] = { text = option.label, value = option.id, func = function(value) service:SetOwnershipFilter(value); refreshPage(parent, snapshot) end }
            end
            return items
        end)
    end)
    ownershipDropdown.Text:SetText(service:GetOwnershipFilterLabel())
    local sortDropdown = createSelectButton(gearPanel, { x = 438, y = -58, w = 220, h = 24 }, service:GetCollectionSortLabel(), function(anchor)
        showDropdownMenu(anchor, function()
            local items = {}
            for _, option in ipairs(collectionSortOptions or {}) do
                items[#items + 1] = { text = option.label, value = option.id, func = function(value) service:SetCollectionSort(value); refreshPage(parent, snapshot) end }
            end
            return items
        end)
    end)
    sortDropdown.Text:SetText(service:GetCollectionSortLabel())

    local tableHost = CreateFrame("Frame", nil, gearPanel)
    tableHost:SetPoint("TOPLEFT", gearPanel, "TOPLEFT", 14, -96)
    tableHost:SetSize((tonumber(gearRect.w) or 1000) - 42, (tonumber(gearRect.h) or 300) - 116)
    renderGearTable(tableHost, gearRows)

    if self.State.textDialog then
        root.TextDialogOverlay = createTextDialogOverlay(root, self.State.textDialog, function()
            self.State.textDialog = nil
            refreshPage(parent, snapshot)
        end, self.State.textDialog.readOnly and nil or function(importText)
            local ok, _, message = service:ImportText(selectedToonId, importText)
            addon:Print(message or (ok and "Imported BIS data." or "Unable to import BIS data."))
            self.State.textDialog = nil
            if ok then
                refreshPage(parent, snapshot)
                return
            end
            refreshPage(parent, snapshot)
        end)
    end

    if self.State.gearSelector then
        root.GearSelectorOverlay = createGearSelectorPopupOverlay(root, self.State.gearSelector, function()
            self.State.gearSelector = nil
            refreshPage(parent, snapshot)
        end, function()
            local selectorState = self.State.gearSelector
            local _, savedCount = service:SaveGearEditorRows(selectorState.toonId, selectorState.selectedSpecId, selectorState.rowsData)
            addon:Print(string.format("Saved %d BIS target%s.", savedCount, savedCount == 1 and "" or "s"))
            self.State.gearSelector = nil
            refreshPage(parent, snapshot)
        end)
    end

    if self.State.showLootHelper then
        root.LootHelperOverlay = createLootHelperOverlay(root, function()
            self.State.showLootHelper = false
            refreshPage(parent, snapshot)
        end, function()
            refreshPage(parent, snapshot)
        end)
    end

    return root
end

return page