local addonName, addonTable = ...
---@type MK
local MK = addonTable.MK
local core = addonTable
local TRANSLATE = core.TRANSLATE or function(msg) return msg end

core.BlacklistTab = core.BlacklistTab or {}
local BlacklistTab = core.BlacklistTab
local MeetingStoneConstants = core.MeetingStoneConstants or {}
local MeetingStoneForm = core.MeetingStoneForm or {}

local CreateSystemButton = MeetingStoneForm.CreateSystemButton
local TrySetAtlas = MeetingStoneForm.TrySetAtlas or function() return false end
local StyleInputBox = MeetingStoneForm.StyleInputBox

local PANEL_INSET_LEFT = MeetingStoneConstants.MEETINGSTONE_PANEL_INSET_LEFT or 5
local PANEL_INSET_TOP = MeetingStoneConstants.MEETINGSTONE_PANEL_INSET_TOP or 0
local PANEL_INSET_RIGHT = MeetingStoneConstants.MEETINGSTONE_PANEL_INSET_RIGHT or 4
local PANEL_INSET_BOTTOM = MeetingStoneConstants.MEETINGSTONE_PANEL_INSET_BOTTOM or 8
local HEADER_TOP_OFFSET = MeetingStoneConstants.MEETINGSTONE_HEADER_TOP_OFFSET or -20
local HEADER_BOTTOM_OFFSET = MeetingStoneConstants.MEETINGSTONE_HEADER_BOTTOM_OFFSET or -46
local HEADER_CONTENT_INSET_X = MeetingStoneConstants.MEETINGSTONE_HEADER_CONTENT_INSET_X or 4
local HEADER_CONTENT_OFFSET_Y = MeetingStoneConstants.MEETINGSTONE_HEADER_CONTENT_OFFSET_Y or 4
local TOP_RIGHT_FILIGREE_OFFSET_X = MeetingStoneConstants.MEETINGSTONE_TOP_RIGHT_FILIGREE_OFFSET_X or 0
local TOP_RIGHT_FILIGREE_OFFSET_Y = MeetingStoneConstants.MEETINGSTONE_TOP_RIGHT_FILIGREE_OFFSET_Y or -3
local BOTTOM_RIGHT_FILIGREE_OFFSET_X = MeetingStoneConstants.MEETINGSTONE_BOTTOM_RIGHT_FILIGREE_OFFSET_X or 0
local BOTTOM_RIGHT_FILIGREE_OFFSET_Y = MeetingStoneConstants.MEETINGSTONE_BOTTOM_RIGHT_FILIGREE_OFFSET_Y or 0

local HEADER_HEIGHT = math.abs(HEADER_BOTTOM_OFFSET - HEADER_TOP_OFFSET)
local ROW_HEIGHT = core.GROUP_LIST_ROW_HEIGHT or 36
local ROW_SIDE_INSET = 14
local ROW_TEXT_INSET = 10
local ROW_COLUMN_OFFSET_X = HEADER_CONTENT_INSET_X
local ROW_GAP = 8
local NOTE_INPUT_HEIGHT = 24
local ACTION_BUTTON_WIDTH = 64
local ACTION_BUTTON_HEIGHT = 26
local ACTION_BUTTON_GAP = 6
local ACTION_COLUMN_MIN_WIDTH = ACTION_BUTTON_WIDTH * 2 + ACTION_BUTTON_GAP + ROW_TEXT_INSET * 2
local ACTION_BUTTON_PRESSED_TEXT_OFFSET_X = 1
local ACTION_BUTTON_PRESSED_TEXT_OFFSET_Y = -1
local SCROLLBAR_WIDTH = MeetingStoneConstants.MEETINGSTONE_APPLICANT_SCROLLBAR_WIDTH or 17
local SCROLLBAR_GAP = MeetingStoneConstants.MEETINGSTONE_APPLICANT_SCROLLBAR_GAP or 2
local SCROLLBAR_RIGHT_INSET = MeetingStoneConstants.MEETINGSTONE_APPLICANT_SCROLLBAR_RIGHT_INSET or 10
local SCROLL_BOTTOM_INSET = MeetingStoneConstants.MEETINGSTONE_APPLICANT_SCROLL_BOTTOM_INSET or 2
local HEADER_TEXT_SIZE = MeetingStoneConstants.MEETINGSTONE_HEADER_TEXT_SIZE or 14
local ApplyHeaderTextStyle = MeetingStoneConstants.ApplyMeetingStoneHeaderTextStyle
local BODY_TEXT_COLOR = { 1, 0.94, 0.82, 1 }
local SECONDARY_TEXT_COLOR = { 0.72, 0.66, 0.5, 1 }
local ROW_BACKGROUND_ALPHA = 1
local ROW_HOVER_ALPHA = 0.16
local HEADER_ACCENT_WIDTH = MeetingStoneConstants.MEETINGSTONE_HEADER_ACCENT_WIDTH or 1.5
local HEADER_ACCENT_HEIGHT = MeetingStoneConstants.MEETINGSTONE_HEADER_ACCENT_HEIGHT or 22
local HEADER_ACCENT_COLOR = MeetingStoneConstants.MEETINGSTONE_HEADER_ACCENT_COLOR or { 126 / 255, 112 / 255, 82 / 255 }
local HEADER_ACCENT_ALPHA = MeetingStoneConstants.MEETINGSTONE_HEADER_ACCENT_ALPHA or 0.6
local GetHeaderAccentPixelWidth = MeetingStoneConstants.GetMeetingStoneHeaderAccentPixelWidth or function()
    return HEADER_ACCENT_WIDTH
end
local SnapHeaderAccentOffset = MeetingStoneConstants.SnapMeetingStoneHeaderAccentOffset or function(_, value)
    return math.floor((tonumber(value) or 0) + 0.5)
end
local ROW_ART_PATH = "Interface\\AddOns\\MyKeyStone\\Art\\Ui\\"
local ROW_TEXTURE_RED = ROW_ART_PATH .. "ApplicantRowRed.png"
local ROW_TEXTURE_SOURCE_WIDTH = 564
local ROW_TEXTURE_SOURCE_CAP_WIDTH = 18
local ROW_TEXTURE_DISPLAY_HEIGHT = core.GROUP_LIST_ROW_BACKGROUND_HEIGHT or 33
local ROW_TEXTURE_DISPLAY_CAP_WIDTH = math.max(
    1,
    math.floor(ROW_TEXTURE_DISPLAY_HEIGHT * ROW_TEXTURE_SOURCE_CAP_WIDTH / 52 + 0.5)
)
local ROW_HOVER_FADE_WIDTH = core.GROUP_LIST_ROW_HOVER_FADE_WIDTH or 96
local ROW_HOVER_HEIGHT = core.GROUP_LIST_ROW_HOVER_HEIGHT or 28
local ROW_HOVER_INSET_X = core.GROUP_LIST_ROW_HOVER_INSET_X or 2
local ROW_HOVER_OFFSET_Y = core.GROUP_LIST_ROW_HOVER_OFFSET_Y or 0
local REASON_BADGE_HEIGHT = 19
local REASON_BADGE_MIN_WIDTH = 62
local REASON_BADGE_MAX_WIDTH = 112
local BLACKLIST_DEFAULT_SORT_KEY = "updated"
local BLACKLIST_CATEGORY_SORT_KEY = "reason"
local REMOVE_DIALOG_WIDTH = 520
local REMOVE_DIALOG_HEIGHT = 132
local REMOVE_DIALOG_ICON_SIZE = 64
local REMOVE_DIALOG_MESSAGE_MAX_WIDTH = REMOVE_DIALOG_WIDTH - REMOVE_DIALOG_ICON_SIZE * 2
local REMOVE_DIALOG_MESSAGE_FALLBACK_WIDTH = REMOVE_DIALOG_MESSAGE_MAX_WIDTH
local REMOVE_DIALOG_MESSAGE_PADDING = 8
local REMOVE_DIALOG_MESSAGE_TOP = -24
local REMOVE_DIALOG_MESSAGE_HEIGHT = 34
local REMOVE_DIALOG_MESSAGE_FONT_SIZE = 20
local REMOVE_DIALOG_BUTTON_WIDTH = 72
local REMOVE_DIALOG_BUTTON_HEIGHT = 26
local REMOVE_DIALOG_BUTTON_GAP = 36
local REMOVE_DIALOG_BUTTON_TOP_GAP = -16
local REMOVE_DIALOG_MAIN_WINDOW_ALPHA = 0.64
local REMOVE_DIALOG_ICON_TEXTURE = "Interface\\AddOns\\" .. addonName .. "\\Art\\Ui\\Icon\\Blacklist.png"
local REMOVE_DIALOG_MESSAGE_KEY = "你确定要将该玩家移除黑名单？"

local REASON_BADGE_STYLE = {
    ad = { text = { 1, 0.84, 0.42, 1 }, border = { 0.72, 0.45, 0.16, 0.64 }, bg = { 0.12, 0.075, 0.028, 0.78 }, glow = { 0.90, 0.34, 0.04, 0.16 } },
    same_title_ad = { text = { 1, 0.84, 0.42, 1 }, border = { 0.72, 0.45, 0.16, 0.64 }, bg = { 0.12, 0.075, 0.028, 0.78 }, glow = { 0.90, 0.34, 0.04, 0.16 } },
    manual = { text = { 1, 0.54, 0.42, 1 }, border = { 0.96, 0.18, 0.10, 0.78 }, bg = { 0.20, 0.025, 0.020, 0.84 }, glow = { 1, 0.05, 0.02, 0.26 } },
}

local REASON_OPTIONS = {
    { value = "ad", textKey = "广告" },
    { value = "same_title_ad", textKey = "标题传染" },
    { value = "manual", textKey = "手动添加" },
}

local REASON_TEXT_KEY_BY_VALUE = {}
local REASON_SORT_ORDER = {}
for index, option in ipairs(REASON_OPTIONS) do
    REASON_TEXT_KEY_BY_VALUE[option.value] = option.textKey
    REASON_SORT_ORDER[option.value] = index
end

local function SnapshotValue(value)
    return tostring(value or ""):gsub("[|~]", " ")
end

local function CreatePlainLabel(parent, text, size, justify)
    local label = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    label:SetText(text or "")
    label:SetJustifyH(justify or "LEFT")
    label:SetJustifyV("MIDDLE")
    label:SetWordWrap(false)
    if core.ApplyFont then
        core.ApplyFont(label, size or 12, "")
    end
    return label
end

local function SetHeaderTextPressed(label, pressed)
    local text = label and label.Text
    if not text then
        return
    end
    label.mkHeaderTextPressed = pressed == true
    if ApplyHeaderTextStyle then
        ApplyHeaderTextStyle(text, label, {
            pressed = pressed,
            hovered = label.mkHeaderTextHovered == true,
            enabled = not (label.IsEnabled and not label:IsEnabled()),
            applyFont = core.ApplyFont,
        })
    end
end

local function SetHeaderTextHovered(label, hovered)
    if not label then
        return
    end
    label.mkHeaderTextHovered = hovered == true
    SetHeaderTextPressed(label, label.mkHeaderTextPressed == true)
end

local function SetHeaderSortTextState(header)
    if not header then
        return
    end
    for _, key in ipairs({ "player", "reason", "note", "updated", "action" }) do
        local cell = header[key]
        local text = cell and cell.Text
        if text then
            SetHeaderTextPressed(cell, cell.mkHeaderTextPressed == true)
        end
    end
end

local function CreateHeaderCell(parent, text, sortableKey)
    local frame = CreateFrame(sortableKey and "Button" or "Frame", nil, parent)
    frame:SetHeight(HEADER_HEIGHT)

    local label = CreatePlainLabel(frame, text, HEADER_TEXT_SIZE, "CENTER")
    frame.Text = label
    SetHeaderTextPressed(frame, false)
    label:SetJustifyH("CENTER")
    label:SetJustifyV("MIDDLE")
    label:SetWordWrap(false)
    label:SetText(text or "")

    if sortableKey then
        frame.blacklistSortKey = sortableKey
        frame:RegisterForClicks("LeftButtonUp")
        frame:SetScript("OnEnter", function(self)
            SetHeaderTextHovered(self, true)
        end)
        frame:SetScript("OnMouseDown", function(self)
            SetHeaderTextPressed(self, true)
        end)
        frame:SetScript("OnMouseUp", function(self)
            SetHeaderTextPressed(self, false)
        end)
        frame:SetScript("OnLeave", function(self)
            SetHeaderTextHovered(self, false)
            SetHeaderTextPressed(self, false)
        end)
        frame:SetScript("OnClick", function(self)
            local widgets = parent.blacklistWidgets
            if not widgets then
                return
            end
            widgets.blacklistSortKey = self.blacklistSortKey or BLACKLIST_DEFAULT_SORT_KEY
            widgets.blacklistEditingKey = nil
            BlacklistTab.Refresh(widgets.blacklistOwner, widgets)
        end)
    end

    return frame
end

local function CreateBlacklistFrameWithTemplateOptions(frameType, name, parent, templates)
    for _, template in ipairs(templates or {}) do
        local ok, frame = pcall(CreateFrame, frameType, name, parent, template)
        if ok and frame then
            return frame
        end
    end
    return CreateFrame(frameType, name, parent)
end

local function FormatBlacklistDateShort(timestamp)
    timestamp = tonumber(timestamp) or 0
    if timestamp > 0 and date then
        return date("%m-%d", timestamp)
    end
    return "-"
end

local function FormatBlacklistDateFull(timestamp)
    timestamp = tonumber(timestamp) or 0
    if timestamp > 0 and date then
        return date("%Y-%m-%d %H:%M", timestamp)
    end
    return "-"
end

local function GetReasonText(reason)
    return TRANSLATE(REASON_TEXT_KEY_BY_VALUE[reason] or "手动添加")
end

local function GetBlacklistEntryReason(entry)
    local note = tostring(entry and entry.note or "")
    local source = tostring(entry and entry.source or "")
    local sourceLower = string.lower(source)

    if sourceLower:find("findgroup_report_ad", 1, true)
        or sourceLower:find("report_ad", 1, true)
        or note == "广告"
    then
        return "ad"
    end
    if sourceLower:find("findgroup_block_title", 1, true)
        or sourceLower:find("block_title", 1, true)
        or note:find("同标题", 1, true)
        or note:find("标题", 1, true)
        or note:find("传染", 1, true)
    then
        return "same_title_ad"
    end
    return "manual"
end

local function GetBlacklistEntrySourceText(reason)
    if reason == "ad" then
        return TRANSLATE("右键举报广告")
    end
    if reason == "same_title_ad" then
        return TRANSLATE("屏蔽同标题广告来源")
    end
    return TRANSLATE("加入黑名单")
end

local function SetBlacklistHeaderAccentGradient(texture, startAlpha, endAlpha)
    if not texture then
        return
    end

    if texture.SetGradient and CreateColor then
        local ok = pcall(
            texture.SetGradient,
            texture,
            "VERTICAL",
            CreateColor(HEADER_ACCENT_COLOR[1], HEADER_ACCENT_COLOR[2], HEADER_ACCENT_COLOR[3], startAlpha),
            CreateColor(HEADER_ACCENT_COLOR[1], HEADER_ACCENT_COLOR[2], HEADER_ACCENT_COLOR[3], endAlpha)
        )
        if ok then
            return
        end
    end

    if texture.SetGradientAlpha then
        texture:SetGradientAlpha(
            "VERTICAL",
            HEADER_ACCENT_COLOR[1], HEADER_ACCENT_COLOR[2], HEADER_ACCENT_COLOR[3], startAlpha,
            HEADER_ACCENT_COLOR[1], HEADER_ACCENT_COLOR[2], HEADER_ACCENT_COLOR[3], endAlpha
        )
    else
        texture:SetVertexColor(HEADER_ACCENT_COLOR[1], HEADER_ACCENT_COLOR[2], HEADER_ACCENT_COLOR[3], math.max(startAlpha or 0, endAlpha or 0))
    end
end

local function LayoutBlacklistHeaderAccent(accent)
    if not accent then
        return
    end

    local height = HEADER_ACCENT_HEIGHT
    local halfHeight = height / 2
    accent:SetSize(GetHeaderAccentPixelWidth(accent), height)

    if accent.top then
        accent.top:ClearAllPoints()
        accent.top:SetPoint("TOPLEFT", accent, "TOPLEFT", 0, 0)
        accent.top:SetPoint("TOPRIGHT", accent, "TOPRIGHT", 0, 0)
        accent.top:SetHeight(halfHeight)
    end
    if accent.bottom then
        accent.bottom:ClearAllPoints()
        accent.bottom:SetPoint("TOPLEFT", accent.top, "BOTTOMLEFT", 0, 0)
        accent.bottom:SetPoint("BOTTOMRIGHT", accent, "BOTTOMRIGHT", 0, 0)
    end
end

local function CreateBlacklistHeaderAccent(parent)
    local accent = CreateFrame("Frame", nil, parent)

    local top = accent:CreateTexture(nil, "OVERLAY")
    top:SetTexture("Interface\\Buttons\\WHITE8X8")
    SetBlacklistHeaderAccentGradient(top, HEADER_ACCENT_ALPHA, 0)

    local bottom = accent:CreateTexture(nil, "OVERLAY")
    bottom:SetTexture("Interface\\Buttons\\WHITE8X8")
    SetBlacklistHeaderAccentGradient(bottom, 0, HEADER_ACCENT_ALPHA)

    accent.top = top
    accent.bottom = bottom
    LayoutBlacklistHeaderAccent(accent)

    return accent
end

local function AddBlacklistHeaderAccentAt(header, offsetX)
    if not header then
        return nil
    end

    local accent = CreateBlacklistHeaderAccent(header)
    LayoutBlacklistHeaderAccent(accent)
    accent:SetPoint("CENTER", header, "LEFT", SnapHeaderAccentOffset(header, offsetX), 0)
    accent:Show()
    return accent
end

local function CenterButtonText(button)
    local text = button and (button.Label or button.Text or (button.GetFontString and button:GetFontString()))
    if not text then
        return
    end
    text:SetJustifyH("CENTER")
    text:SetJustifyV("MIDDLE")
end

local function SetBlacklistActionButtonLabelOffset(button, pressed)
    local label = button and button.Label
    if not label then
        return
    end

    label:ClearAllPoints()
    label:SetPoint(
        "CENTER",
        button,
        "CENTER",
        pressed and ACTION_BUTTON_PRESSED_TEXT_OFFSET_X or 0,
        pressed and ACTION_BUTTON_PRESSED_TEXT_OFFSET_Y or 0
    )
end

local function SetBlacklistActionButtonVisual(button, state)
    if not button then
        return
    end

    local disabled = button.mkBlacklistActionDisabled == true
    local pressed = state == "pressed" and not disabled
    local hovered = state == "hover" and not disabled
    button.mkMeetingStoneButtonPressed = pressed and true or nil
    if button.HoverTexture then
        button.HoverTexture:SetShown(hovered)
    end
    if button.PressedTexture then
        button.PressedTexture:SetShown(pressed)
    end
    if core.MeetingStoneButtonArt and core.MeetingStoneButtonArt.Update then
        core.MeetingStoneButtonArt.Update(button)
    end

    if button.Label then
        SetBlacklistActionButtonLabelOffset(button, pressed)
        if disabled then
            button.Label:SetTextColor(0.48, 0.48, 0.48, 1)
        elseif pressed then
            button.Label:SetTextColor(1, 0.92, 0.48, 1)
        elseif hovered then
            button.Label:SetTextColor(1, 1, 0.72, 1)
        elseif button.mkDanger then
            button.Label:SetTextColor(1, 0.82, 0, 1)
        else
            button.Label:SetTextColor(1, 0.82, 0, 1)
        end
    end
end

local function CreateTextActionButton(parent, text, danger)
    local button = CreateSystemButton and CreateSystemButton(parent, nil, ACTION_BUTTON_WIDTH, ACTION_BUTTON_HEIGHT, text, 0)
        or CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    button:SetSize(ACTION_BUTTON_WIDTH, ACTION_BUTTON_HEIGHT)
    if button.SetText then
        button:SetText(text or "")
    end
    local label = button.Text or button:GetFontString()
    if label then
        label:ClearAllPoints()
        label:SetPoint("CENTER", button, "CENTER", 0, 0)
        label:SetJustifyH("CENTER")
        label:SetJustifyV("MIDDLE")
    end
    if label and core.ApplyFont then
        core.ApplyFont(label, 12, "")
    end
    button.Label = label
    button.mkDanger = danger == true
    CenterButtonText(button)

    SetBlacklistActionButtonVisual(button, "normal")
    return button
end

local function SetBlacklistActionButtonEnabled(button, enabled)
    if not button then
        return
    end

    enabled = enabled == true
    button.mkBlacklistActionDisabled = not enabled
    if button.SetEnabled then
        button:SetEnabled(enabled)
    elseif button.Enable and button.Disable then
        if enabled then
            button:Enable()
        else
            button:Disable()
        end
    end

    button:SetAlpha((enabled or button.mkMeetingStoneButtonArtApplied == true) and 1 or 0.42)
    SetBlacklistActionButtonVisual(button, "normal")
end

local function CreateBlacklistDialogButton(parent, text, danger)
    local button = CreateSystemButton and CreateSystemButton(parent, nil, REMOVE_DIALOG_BUTTON_WIDTH, REMOVE_DIALOG_BUTTON_HEIGHT, text, 0)
        or CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    button:SetSize(REMOVE_DIALOG_BUTTON_WIDTH, REMOVE_DIALOG_BUTTON_HEIGHT)
    if button.SetText then
        button:SetText(text or "")
    end

    local label = button.Text or button:GetFontString()
    if label then
        label:ClearAllPoints()
        label:SetPoint("CENTER", button, "CENTER", 0, 0)
        label:SetJustifyH("CENTER")
        label:SetJustifyV("MIDDLE")
        if core.ApplyFont then
            core.ApplyFont(label, 15, "")
        end
    end
    button.Label = label
    button.mkDanger = danger == true
    CenterButtonText(button)
    SetBlacklistActionButtonVisual(button, "normal")

    button:SetScript("OnEnter", function(self)
        SetBlacklistActionButtonVisual(self, "hover")
    end)
    button:SetScript("OnLeave", function(self)
        SetBlacklistActionButtonVisual(self, "normal")
    end)
    button:SetScript("OnMouseDown", function(self, mouseButton)
        if mouseButton == "LeftButton" then
            SetBlacklistActionButtonVisual(self, "pressed")
        end
    end)
    button:SetScript("OnMouseUp", function(self)
        if self.IsMouseOver and self:IsMouseOver() then
            SetBlacklistActionButtonVisual(self, "hover")
        else
            SetBlacklistActionButtonVisual(self, "normal")
        end
    end)

    return button
end

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

    local owner = dialog.blacklistOwner
    local key = dialog.blacklistKey
    dialog:Hide()
    if owner and owner.RemovePlayerFromBlacklist and key then
        local ok, removed = owner:RemovePlayerFromBlacklist(key)
        if ok and owner.PrintBlacklistRemoved then
            owner:PrintBlacklistRemoved(removed)
        end
    end
end

local function GetBlacklistRemoveDialogMainWindow(dialog)
    local owner = dialog and dialog.blacklistOwner
    local mainWindow = owner and owner.mainWindow
    if mainWindow and mainWindow.frame then
        mainWindow = mainWindow.frame
    end
    if mainWindow and mainWindow.SetAlpha then
        return mainWindow
    end
    return nil
end

local function SetBlacklistRemoveDialogMainWindowAlpha(dialog, transparent)
    local mainWindow = GetBlacklistRemoveDialogMainWindow(dialog)
    if not mainWindow then
        return
    end

    if transparent then
        if dialog.mkMainWindowPreviousAlpha == nil then
            dialog.mkMainWindowPreviousAlpha = mainWindow.GetAlpha and mainWindow:GetAlpha() or 1
        end
        mainWindow:SetAlpha(REMOVE_DIALOG_MAIN_WINDOW_ALPHA)
        return
    end

    if dialog.mkMainWindowPreviousAlpha == nil then
        return
    end
    local previousAlpha = tonumber(dialog.mkMainWindowPreviousAlpha) or 1
    dialog.mkMainWindowPreviousAlpha = nil
    mainWindow:SetAlpha(previousAlpha)
end

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

    local message = dialog.Message
    local icon = dialog.Icon
    local yesButton = dialog.YesButton
    local noButton = dialog.NoButton
    local dialogWidth = (dialog.GetWidth and dialog:GetWidth()) or REMOVE_DIALOG_WIDTH
    local textWidth = REMOVE_DIALOG_MESSAGE_FALLBACK_WIDTH
    if message and message.GetStringWidth then
        textWidth = math.ceil(message:GetStringWidth() or 0)
        if textWidth <= 0 then
            textWidth = REMOVE_DIALOG_MESSAGE_FALLBACK_WIDTH
        end
    end
    textWidth = math.min(textWidth, REMOVE_DIALOG_MESSAGE_MAX_WIDTH)

    if message then
        message:ClearAllPoints()
        message:SetPoint("TOP", dialog, "TOP", 0, REMOVE_DIALOG_MESSAGE_TOP)
        message:SetSize(textWidth + REMOVE_DIALOG_MESSAGE_PADDING * 2, REMOVE_DIALOG_MESSAGE_HEIGHT)
    end

    if icon then
        local leftBorderX = -dialogWidth / 2
        local textLeftX = -textWidth / 2
        local iconCenterX = (leftBorderX + textLeftX) / 2
        icon:ClearAllPoints()
        icon:SetPoint("CENTER", dialog, "CENTER", iconCenterX, 0)
    end

    if yesButton and message then
        yesButton:ClearAllPoints()
        yesButton:SetPoint(
            "TOP",
            message,
            "BOTTOM",
            -((REMOVE_DIALOG_BUTTON_WIDTH + REMOVE_DIALOG_BUTTON_GAP) / 2),
            REMOVE_DIALOG_BUTTON_TOP_GAP
        )
    end

    if noButton and message then
        noButton:ClearAllPoints()
        noButton:SetPoint(
            "TOP",
            message,
            "BOTTOM",
            (REMOVE_DIALOG_BUTTON_WIDTH + REMOVE_DIALOG_BUTTON_GAP) / 2,
            REMOVE_DIALOG_BUTTON_TOP_GAP
        )
    end
end

local function CreateBlacklistRemoveDialog()
    local dialog = CreateFrame("Frame", "MKBlacklistRemoveDialog", UIParent, "BackdropTemplate")
    dialog:SetSize(REMOVE_DIALOG_WIDTH, REMOVE_DIALOG_HEIGHT)
    dialog:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    dialog:SetFrameStrata("DIALOG")
    dialog:SetFrameLevel(1000)
    dialog:SetToplevel(true)
    dialog:EnableMouse(true)
    dialog:Hide()

    if dialog.SetBackdrop then
        dialog:SetBackdrop({
            bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
            edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
            tile = true,
            tileSize = 32,
            edgeSize = 32,
            insets = { left = 11, right = 12, top = 12, bottom = 11 },
        })
        dialog:SetBackdropColor(0, 0, 0, 0.9)
        dialog:SetBackdropBorderColor(0.82, 0.82, 0.82, 1)
    else
        local background = dialog:CreateTexture(nil, "BACKGROUND")
        background:SetAllPoints(dialog)
        background:SetTexture("Interface\\Buttons\\WHITE8X8")
        background:SetVertexColor(0, 0, 0, 0.9)
        dialog.Background = background
    end

    local icon = dialog:CreateTexture(nil, "ARTWORK")
    icon:SetSize(REMOVE_DIALOG_ICON_SIZE, REMOVE_DIALOG_ICON_SIZE)
    icon:SetTexture(REMOVE_DIALOG_ICON_TEXTURE)
    dialog.Icon = icon

    local message = dialog:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    message:SetJustifyH("CENTER")
    message:SetJustifyV("MIDDLE")
    message:SetTextColor(1, 1, 1, 1)
    message:SetWordWrap(false)
    if core.ApplyFont then
        core.ApplyFont(message, REMOVE_DIALOG_MESSAGE_FONT_SIZE, "")
    end
    dialog.Message = message

    local yesButton = CreateBlacklistDialogButton(dialog, TRANSLATE("是"), true)
    yesButton:SetScript("OnClick", function()
        if core.PlayPanelButtonClickSound then
            core.PlayPanelButtonClickSound()
        end
        ConfirmBlacklistRemove(dialog)
    end)
    dialog.YesButton = yesButton

    local noButton = CreateBlacklistDialogButton(dialog, TRANSLATE("否"))
    noButton:SetScript("OnClick", function()
        if core.PlayPanelButtonClickSound then
            core.PlayPanelButtonClickSound()
        end
        dialog:Hide()
    end)
    dialog.NoButton = noButton

    LayoutBlacklistRemoveDialog(dialog)

    dialog:SetScript("OnShow", function(self)
        LayoutBlacklistRemoveDialog(self)
        SetBlacklistRemoveDialogMainWindowAlpha(self, true)
        if self.EnableKeyboard then
            self:EnableKeyboard(true)
        end
    end)
    dialog:SetScript("OnHide", function(self)
        SetBlacklistRemoveDialogMainWindowAlpha(self, false)
        self.blacklistOwner = nil
        self.blacklistKey = nil
        if self.EnableKeyboard then
            self:EnableKeyboard(false)
        end
    end)
    dialog:SetScript("OnKeyDown", function(self, key)
        if key == "ESCAPE" then
            self:Hide()
        end
    end)

    return dialog
end

local function GetBlacklistRemoveDialog()
    if not BlacklistTab.RemoveDialog then
        BlacklistTab.RemoveDialog = CreateBlacklistRemoveDialog()
    end
    return BlacklistTab.RemoveDialog
end

local function SetBlacklistRowTexturePiecesAlpha(pieces, alpha)
    for _, piece in pairs(pieces or {}) do
        if piece and piece.SetAlpha then
            piece:SetAlpha(alpha or 1)
        end
    end
end

local function CreateBlacklistRowBackgroundPieces(row, subLevel)
    local capWidth = ROW_TEXTURE_DISPLAY_CAP_WIDTH
    local leftTexCoord = ROW_TEXTURE_SOURCE_CAP_WIDTH / ROW_TEXTURE_SOURCE_WIDTH
    local rightTexCoord = 1 - leftTexCoord
    subLevel = subLevel or 0

    local left = row:CreateTexture(nil, "BACKGROUND", nil, subLevel)
    left:SetPoint("LEFT", row, "LEFT", 0, 0)
    left:SetSize(capWidth, ROW_TEXTURE_DISPLAY_HEIGHT)
    left:SetTexture(ROW_TEXTURE_RED)
    left:SetTexCoord(0, leftTexCoord, 0, 1)

    local right = row:CreateTexture(nil, "BACKGROUND", nil, subLevel)
    right:SetPoint("RIGHT", row, "RIGHT", 0, 0)
    right:SetSize(capWidth, ROW_TEXTURE_DISPLAY_HEIGHT)
    right:SetTexture(ROW_TEXTURE_RED)
    right:SetTexCoord(rightTexCoord, 1, 0, 1)

    local middle = row:CreateTexture(nil, "BACKGROUND", nil, subLevel)
    middle:SetPoint("LEFT", left, "RIGHT", 0, 0)
    middle:SetPoint("RIGHT", right, "LEFT", 0, 0)
    middle:SetHeight(ROW_TEXTURE_DISPLAY_HEIGHT)
    middle:SetTexture(ROW_TEXTURE_RED)
    middle:SetTexCoord(leftTexCoord, rightTexCoord, 0, 1)

    return {
        left = left,
        middle = middle,
        right = right,
    }
end

local function SetBlacklistRowBackgroundAlpha(row, alpha)
    if row then
        SetBlacklistRowTexturePiecesAlpha(row.BackgroundPieces, alpha)
    end
end

local function SetBlacklistHoverGradient(texture, direction, r, g, b, alpha)
    if not texture then
        return
    end
    if texture.SetGradient and CreateColor then
        if direction == "LEFT" then
            texture:SetGradient("HORIZONTAL", CreateColor(r, g, b, 0), CreateColor(r, g, b, alpha))
        elseif direction == "RIGHT" then
            texture:SetGradient("HORIZONTAL", CreateColor(r, g, b, alpha), CreateColor(r, g, b, 0))
        else
            texture:SetVertexColor(r, g, b, alpha)
        end
    else
        texture:SetVertexColor(r, g, b, direction and alpha * 0.66 or alpha)
    end
end

local function SetBlacklistRowHoverColor(row, r, g, b, alpha)
    if not row then
        return
    end
    r, g, b, alpha = r or 1, g or 0.12, b or 0.08, alpha or ROW_HOVER_ALPHA
    SetBlacklistHoverGradient(row.HoverLeft, "LEFT", r, g, b, alpha)
    SetBlacklistHoverGradient(row.HoverRight, "RIGHT", r, g, b, alpha)
    if row.Hover then
        row.Hover:SetVertexColor(r, g, b, alpha)
    end
end

local function CreateBlacklistScrollContainer(parent)
    local scrollFrame = CreateFrame("ScrollFrame", nil, parent)
    scrollFrame:EnableMouseWheel(true)

    local content = CreateFrame("Frame", nil, scrollFrame)
    content:SetPoint("TOPLEFT", scrollFrame, "TOPLEFT", 0, 0)
    content:SetWidth(1)
    content:SetHeight(1)
    scrollFrame:SetScrollChild(content)

    local scrollBar = CreateBlacklistFrameWithTemplateOptions("EventFrame", nil, parent, { "MinimalScrollBar" })
    if not scrollBar or (not scrollBar.SetValue and not scrollBar.SetScrollPercentage) then
        scrollBar = CreateFrame("Slider", nil, parent)
        scrollBar:SetOrientation("VERTICAL")
        scrollBar:SetValueStep(1)
        if scrollBar.SetObeyStepOnDrag then
            scrollBar:SetObeyStepOnDrag(true)
        end
    end
    scrollBar:SetWidth(SCROLLBAR_WIDTH)
    if scrollBar.SetMinMaxValues then
        scrollBar:SetMinMaxValues(0, 0)
    end
    scrollBar:Hide()

    local useNativeMinimalScrollBar = scrollBar.Init and ScrollUtil and ScrollUtil.InitScrollFrameWithScrollBar
    if useNativeMinimalScrollBar then
        ScrollUtil.InitScrollFrameWithScrollBar(scrollFrame, scrollBar)
    end

    local isSyncingScroll = false
    local scrollMinValue = 0
    local scrollMaxValue = 0

    local function setScrollBarRange(minValue, maxValue)
        scrollMinValue = minValue or 0
        scrollMaxValue = maxValue or 0
        if scrollBar.SetMinMaxValues then
            scrollBar:SetMinMaxValues(scrollMinValue, scrollMaxValue)
        elseif scrollBar.SetScrollPercentage then
            scrollBar:SetScrollPercentage(0)
        end
    end
    scrollBar.mkSetScrollRange = setScrollBarRange

    local function getScrollBarValue()
        if useNativeMinimalScrollBar and scrollBar.GetScrollPercentage then
            return (scrollBar:GetScrollPercentage() or 0) * scrollMaxValue
        end
        if scrollBar.GetValue then
            return scrollBar:GetValue() or 0
        end
        if scrollBar.GetScrollPercentage then
            return (scrollBar:GetScrollPercentage() or 0) * scrollMaxValue
        end
        return 0
    end
    scrollBar.mkGetScrollValue = getScrollBarValue

    local function setScrollBarValue(value)
        if useNativeMinimalScrollBar and scrollBar.SetScrollPercentage then
            local percentage = scrollMaxValue > 0 and ((value or 0) / scrollMaxValue) or 0
            scrollBar:SetScrollPercentage(math.min(math.max(percentage, 0), 1), ScrollBoxConstants and ScrollBoxConstants.NoScrollInterpolation)
        elseif scrollBar.SetValue then
            scrollBar:SetValue(value or 0)
        elseif scrollBar.SetScrollPercentage then
            local percentage = scrollMaxValue > 0 and ((value or 0) / scrollMaxValue) or 0
            scrollBar:SetScrollPercentage(math.min(math.max(percentage, 0), 1))
        end
    end
    scrollBar.mkSetScrollValue = setScrollBarValue

    local function applyScrollValue(value)
        local nextValue = math.min(math.max(value or 0, scrollMinValue), scrollMaxValue)
        isSyncingScroll = true
        scrollFrame:SetVerticalScroll(nextValue)
        if math.abs(getScrollBarValue() - nextValue) > 0.5 then
            setScrollBarValue(nextValue)
        end
        isSyncingScroll = false
        if scrollFrame.mkOnScroll then
            scrollFrame.mkOnScroll()
        end
    end
    scrollFrame.mkApplyScrollValue = applyScrollValue

    if scrollBar.SetScrollPercentage and scrollBar.RegisterCallback and scrollBar.Event and scrollBar.Event.OnScroll then
        scrollBar:RegisterCallback(scrollBar.Event.OnScroll, function(_, scrollPercentage)
            if not isSyncingScroll then
                applyScrollValue((scrollPercentage or 0) * scrollMaxValue)
            end
        end)
    elseif scrollBar.SetScript and scrollBar.SetValue then
        scrollBar:SetScript("OnValueChanged", function(_, value)
            if not isSyncingScroll then
                applyScrollValue(value or getScrollBarValue())
            end
        end)
    end
    setScrollBarValue(0)

    if not useNativeMinimalScrollBar then
        scrollFrame:SetScript("OnVerticalScroll", function(_, offset)
            applyScrollValue(offset)
        end)
        scrollFrame:SetScript("OnMouseWheel", function(_, delta)
            applyScrollValue(getScrollBarValue() - (delta or 0) * ROW_HEIGHT)
        end)
    else
        scrollFrame:SetScript("OnMouseWheel", function(_, delta)
            applyScrollValue(getScrollBarValue() - (delta or 0) * ROW_HEIGHT)
        end)
    end

    scrollFrame:SetScript("OnSizeChanged", function(self)
        content:SetWidth(math.max((self:GetWidth() or 1), 1))
        if self.mkOnSizeChanged then
            self.mkOnSizeChanged()
        end
    end)

    return scrollFrame, content, scrollBar
end

local function GetBlacklistColumnLayout(width)
    width = math.max(1, math.floor(tonumber(width) or 1))
    local player = math.min(math.max(260, math.floor(width * 0.28)), 430)
    local reason = math.min(math.max(112, math.floor(width * 0.11)), 150)
    local updated = math.min(math.max(86, math.floor(width * 0.08)), 116)
    local action = math.min(math.max(ACTION_COLUMN_MIN_WIDTH, math.floor(width * 0.10)), ACTION_COLUMN_MIN_WIDTH + 16)
    local note = width - player - reason - updated - action

    if note < 260 then
        local shortage = 260 - note
        player = math.max(180, player - shortage)
        note = width - player - reason - updated - action
    end
    note = math.max(220, note)

    local layout = {
        player = { x = 0, width = player },
        reason = { x = player, width = reason },
        note = { x = player + reason, width = note },
        updated = { x = player + reason + note, width = updated },
        action = { x = player + reason + note + updated, width = action },
    }
    layout.totalWidth = player + reason + note + updated + action
    return layout
end

local function ApplyBlacklistColumnFrame(frame, layout)
    frame.player:ClearAllPoints()
    frame.player:SetPoint("LEFT", frame, "LEFT", layout.player.x, 0)
    frame.player:SetSize(layout.player.width, frame:GetHeight() or HEADER_HEIGHT)

    frame.reason:ClearAllPoints()
    frame.reason:SetPoint("LEFT", frame, "LEFT", layout.reason.x, 0)
    frame.reason:SetSize(layout.reason.width, frame:GetHeight() or HEADER_HEIGHT)

    frame.note:ClearAllPoints()
    frame.note:SetPoint("LEFT", frame, "LEFT", layout.note.x, 0)
    frame.note:SetSize(layout.note.width, frame:GetHeight() or HEADER_HEIGHT)

    frame.updated:ClearAllPoints()
    frame.updated:SetPoint("LEFT", frame, "LEFT", layout.updated.x, 0)
    frame.updated:SetSize(layout.updated.width, frame:GetHeight() or HEADER_HEIGHT)

    frame.action:ClearAllPoints()
    frame.action:SetPoint("LEFT", frame, "LEFT", layout.action.x, 0)
    frame.action:SetSize(layout.action.width, frame:GetHeight() or HEADER_HEIGHT)
end

local function AddBlacklistTooltipDoubleLine(labelKey, value)
    if not (GameTooltip and value and value ~= "") then
        return
    end
    GameTooltip:AddDoubleLine(
        TRANSLATE(labelKey),
        tostring(value),
        1, 0.82, 0,
        0.9, 0.82, 0.64
    )
end

local function ShowBlacklistEntryTooltip(anchor, entry)
    if not (anchor and GameTooltip) then
        return
    end

    local displayName = entry and (entry.displayName or entry.name or entry.key) or ""
    local reason = GetBlacklistEntryReason(entry)
    local note = tostring(entry and entry.note or "")
    local updatedAt = entry and (entry.updatedAt or entry.addedAt) or nil

    GameTooltip:SetOwner(anchor, "ANCHOR_RIGHT")
    GameTooltip:ClearLines()

    if displayName ~= "" then
        GameTooltip:AddLine(displayName, 1, 0.82, 0, true)
    end
    AddBlacklistTooltipDoubleLine("类别", GetReasonText(reason))
    AddBlacklistTooltipDoubleLine("来源", GetBlacklistEntrySourceText(reason))
    if entry and entry.realm and entry.realm ~= "" then
        AddBlacklistTooltipDoubleLine("服务器", entry.realm)
    end
    AddBlacklistTooltipDoubleLine("更新时间", FormatBlacklistDateFull(updatedAt))

    GameTooltip:AddLine(" ")
    GameTooltip:AddLine(TRANSLATE("备注"), 1, 0.82, 0, true)
    GameTooltip:AddLine(note ~= "" and note or TRANSLATE("无备注"), 0.9, 0.82, 0.64, true)
    GameTooltip:Show()
end

local function HideBlacklistTooltip()
    if GameTooltip then
        GameTooltip:Hide()
    end
end

local function ApplyBlacklistRowVisualState(row)
    if not row then
        return
    end

    local isHover = row.mkHoverShown == true

    if row.Hover then
        row.Hover:SetShown(isHover)
    end
    if row.HoverLeft then
        row.HoverLeft:SetShown(isHover)
    end
    if row.HoverRight then
        row.HoverRight:SetShown(isHover)
    end
end

local function SetRowHover(row, shown)
    if not row then
        return
    end
    row.mkHoverShown = shown == true
    ApplyBlacklistRowVisualState(row)
end

local function SetRowEditing(row, editing)
    if not row then
        return
    end
    row.mkIsEditing = editing == true
end

local function SetBlacklistReasonBadgeStyle(reasonFrame, reason)
    if not reasonFrame then
        return
    end

    local style = REASON_BADGE_STYLE[reason] or REASON_BADGE_STYLE.manual
    local bg = style.bg or REASON_BADGE_STYLE.manual.bg
    local border = style.border or REASON_BADGE_STYLE.manual.border
    local glow = style.glow or REASON_BADGE_STYLE.manual.glow
    local text = style.text or REASON_BADGE_STYLE.manual.text

    reasonFrame.Shadow:SetVertexColor(0, 0, 0, 0.52)
    reasonFrame.Background:SetVertexColor(bg[1], bg[2], bg[3], bg[4])
    reasonFrame.Glow:SetVertexColor(glow[1], glow[2], glow[3], glow[4])
    reasonFrame.Sheen:SetVertexColor(1, 0.86, 0.44, 0.10)
    reasonFrame.TopLine:SetVertexColor(border[1], border[2], border[3], border[4])
    reasonFrame.BottomLine:SetVertexColor(border[1], border[2], border[3], math.min((border[4] or 0.44) * 0.6, 1))
    reasonFrame.LeftLine:SetVertexColor(border[1], border[2], border[3], math.min((border[4] or 0.44) * 0.75, 1))
    reasonFrame.RightLine:SetVertexColor(border[1], border[2], border[3], math.min((border[4] or 0.44) * 0.45, 1))
    reasonFrame.Label:SetTextColor(text[1], text[2], text[3], text[4])
end

local function CreateBlacklistRow(parent)
    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(ROW_HEIGHT)

    local background = row:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(row)
    background:SetTexture("Interface\\Buttons\\WHITE8X8")
    background:SetVertexColor(0, 0, 0, 0.22)
    row.Background = background

    row.BackgroundPieces = CreateBlacklistRowBackgroundPieces(row, -1)
    SetBlacklistRowBackgroundAlpha(row, ROW_BACKGROUND_ALPHA)

    local hoverLeft = row:CreateTexture(nil, "BORDER", nil, 3)
    hoverLeft:SetPoint("LEFT", row, "LEFT", ROW_HOVER_INSET_X, ROW_HOVER_OFFSET_Y)
    hoverLeft:SetSize(ROW_HOVER_FADE_WIDTH, ROW_HOVER_HEIGHT)
    hoverLeft:SetTexture("Interface\\Buttons\\WHITE8X8")
    hoverLeft:Hide()
    row.HoverLeft = hoverLeft

    local hoverRight = row:CreateTexture(nil, "BORDER", nil, 3)
    hoverRight:SetPoint("RIGHT", row, "RIGHT", -ROW_HOVER_INSET_X, ROW_HOVER_OFFSET_Y)
    hoverRight:SetSize(ROW_HOVER_FADE_WIDTH, ROW_HOVER_HEIGHT)
    hoverRight:SetTexture("Interface\\Buttons\\WHITE8X8")
    hoverRight:Hide()
    row.HoverRight = hoverRight

    local hover = row:CreateTexture(nil, "BORDER", nil, 3)
    hover:SetPoint("LEFT", hoverLeft, "RIGHT", 0, 0)
    hover:SetPoint("RIGHT", hoverRight, "LEFT", 0, 0)
    hover:SetHeight(ROW_HOVER_HEIGHT)
    hover:SetTexture("Interface\\Buttons\\WHITE8X8")
    hover:Hide()
    row.Hover = hover
    SetBlacklistRowHoverColor(row, 1, 0.08, 0.05, ROW_HOVER_ALPHA)

    local name = CreatePlainLabel(row, "", 13, "LEFT")
    name:SetTextColor(BODY_TEXT_COLOR[1], BODY_TEXT_COLOR[2], BODY_TEXT_COLOR[3], BODY_TEXT_COLOR[4])
    row.Name = name

    local nameHitBox = CreateFrame("Frame", nil, row)
    nameHitBox:EnableMouse(true)
    row.NameHitBox = nameHitBox

    local reasonFrame = CreateFrame("Frame", nil, row)
    reasonFrame.Shadow = reasonFrame:CreateTexture(nil, "BACKGROUND", nil, -2)
    reasonFrame.Shadow:SetPoint("TOPLEFT", reasonFrame, "TOPLEFT", -1, 1)
    reasonFrame.Shadow:SetPoint("BOTTOMRIGHT", reasonFrame, "BOTTOMRIGHT", 1, -1)
    reasonFrame.Shadow:SetTexture("Interface\\Buttons\\WHITE8X8")

    reasonFrame.Background = reasonFrame:CreateTexture(nil, "BACKGROUND", nil, -1)
    reasonFrame.Background:SetTexture("Interface\\Buttons\\WHITE8X8")
    reasonFrame.Background:SetAllPoints(reasonFrame)
    reasonFrame.Background:SetVertexColor(0.1, 0.075, 0.032, 0.82)

    local glow = reasonFrame:CreateTexture(nil, "BORDER", nil, 0)
    glow:SetPoint("TOPLEFT", reasonFrame, "TOPLEFT", 1, -1)
    glow:SetPoint("BOTTOMRIGHT", reasonFrame, "BOTTOMRIGHT", -1, 1)
    glow:SetTexture("Interface\\Buttons\\WHITE8X8")
    reasonFrame.Glow = glow

    local sheen = reasonFrame:CreateTexture(nil, "BORDER", nil, 1)
    sheen:SetPoint("TOPLEFT", reasonFrame, "TOPLEFT", 1, -1)
    sheen:SetPoint("TOPRIGHT", reasonFrame, "TOPRIGHT", -1, -1)
    sheen:SetHeight(7)
    sheen:SetTexture("Interface\\Buttons\\WHITE8X8")
    reasonFrame.Sheen = sheen

    local topLine = reasonFrame:CreateTexture(nil, "BORDER")
    topLine:SetTexture("Interface\\Buttons\\WHITE8X8")
    topLine:SetPoint("TOPLEFT", reasonFrame, "TOPLEFT", 1, -1)
    topLine:SetPoint("TOPRIGHT", reasonFrame, "TOPRIGHT", -1, -1)
    topLine:SetHeight(1)
    reasonFrame.TopLine = topLine

    local bottomLine = reasonFrame:CreateTexture(nil, "BORDER")
    bottomLine:SetTexture("Interface\\Buttons\\WHITE8X8")
    bottomLine:SetPoint("BOTTOMLEFT", reasonFrame, "BOTTOMLEFT", 1, 1)
    bottomLine:SetPoint("BOTTOMRIGHT", reasonFrame, "BOTTOMRIGHT", -1, 1)
    bottomLine:SetHeight(1)
    reasonFrame.BottomLine = bottomLine

    local leftLine = reasonFrame:CreateTexture(nil, "BORDER")
    leftLine:SetTexture("Interface\\Buttons\\WHITE8X8")
    leftLine:SetPoint("TOPLEFT", reasonFrame, "TOPLEFT", 1, -1)
    leftLine:SetPoint("BOTTOMLEFT", reasonFrame, "BOTTOMLEFT", 1, 1)
    leftLine:SetWidth(1)
    reasonFrame.LeftLine = leftLine

    local rightLine = reasonFrame:CreateTexture(nil, "BORDER")
    rightLine:SetTexture("Interface\\Buttons\\WHITE8X8")
    rightLine:SetPoint("TOPRIGHT", reasonFrame, "TOPRIGHT", -1, -1)
    rightLine:SetPoint("BOTTOMRIGHT", reasonFrame, "BOTTOMRIGHT", -1, 1)
    rightLine:SetWidth(1)
    reasonFrame.RightLine = rightLine

    local markerGlow = reasonFrame:CreateTexture(nil, "ARTWORK", nil, 1)
    markerGlow:SetPoint("LEFT", reasonFrame, "LEFT", 6, 0)
    markerGlow:SetSize(8, 14)
    markerGlow:SetTexture("Interface\\Buttons\\WHITE8X8")
    markerGlow:Hide()
    reasonFrame.MarkerGlow = markerGlow

    local marker = reasonFrame:CreateTexture(nil, "ARTWORK", nil, 2)
    marker:SetPoint("LEFT", reasonFrame, "LEFT", 8, 0)
    marker:SetSize(3, 12)
    marker:SetTexture("Interface\\Buttons\\WHITE8X8")
    marker:Hide()
    reasonFrame.Marker = marker

    reasonFrame.Label = CreatePlainLabel(reasonFrame, "", 12, "CENTER")
    reasonFrame.Label:SetPoint("LEFT", reasonFrame, "LEFT", 6, 0)
    reasonFrame.Label:SetPoint("RIGHT", reasonFrame, "RIGHT", -6, 0)
    reasonFrame.Label:SetHeight(REASON_BADGE_HEIGHT)
    reasonFrame.Label:SetJustifyH("CENTER")
    reasonFrame.Label:SetTextColor(0.95, 0.78, 0.36, 1)
    row.Reason = reasonFrame

    local noteText = CreatePlainLabel(row, "", 12, "LEFT")
    noteText:SetTextColor(BODY_TEXT_COLOR[1], BODY_TEXT_COLOR[2], BODY_TEXT_COLOR[3], BODY_TEXT_COLOR[4])
    row.NoteText = noteText

    local noteHitBox = CreateFrame("Button", nil, row)
    noteHitBox:EnableMouse(true)
    row.NoteHitBox = noteHitBox

    local noteBox = CreateFrame("EditBox", nil, row, "InputBoxTemplate")
    noteBox:SetAutoFocus(false)
    noteBox:SetMaxLetters(140)
    noteBox:SetHeight(NOTE_INPUT_HEIGHT)
    noteBox:SetTextColor(1, 0.96, 0.86, 1)
    noteBox:SetJustifyH("LEFT")
    noteBox:SetJustifyV("MIDDLE")
    if core.ApplyFont then
        core.ApplyFont(noteBox, 12, "")
    end
    if StyleInputBox then
        StyleInputBox(noteBox)
    end
    noteBox:Hide()
    row.NoteBox = noteBox

    local dateFrame = CreateFrame("Frame", nil, row)
    dateFrame:EnableMouse(true)
    row.DateFrame = dateFrame

    local dateLabel = CreatePlainLabel(dateFrame, "", 12, "CENTER")
    dateLabel:SetAllPoints(dateFrame)
    dateLabel:SetTextColor(SECONDARY_TEXT_COLOR[1], SECONDARY_TEXT_COLOR[2], SECONDARY_TEXT_COLOR[3], SECONDARY_TEXT_COLOR[4])
    row.Date = dateLabel

    local editButton = CreateTextActionButton(row, TRANSLATE("编辑"))
    local removeButton = CreateTextActionButton(row, TRANSLATE("移除"), true)
    local saveButton = CreateTextActionButton(row, TRANSLATE("保存"))
    local cancelButton = CreateTextActionButton(row, TRANSLATE("取消"))
    row.EditButton = editButton
    row.RemoveButton = removeButton
    row.SaveButton = saveButton
    row.CancelButton = cancelButton

    row:SetScript("OnEnter", function(self) SetRowHover(self, true) end)
    row:SetScript("OnLeave", function(self) SetRowHover(self, false) end)
    for _, child in ipairs({ nameHitBox, noteHitBox, dateFrame }) do
        child:SetScript("OnEnter", function()
            SetRowHover(row, true)
        end)
        child:SetScript("OnLeave", function()
            SetRowHover(row, false)
            if child == nameHitBox or child == noteHitBox or child == dateFrame then
                HideBlacklistTooltip()
            end
        end)
    end
    for _, button in ipairs({ editButton, removeButton, saveButton, cancelButton }) do
        button:SetScript("OnEnter", function(self)
            SetRowHover(row, true)
            SetBlacklistActionButtonVisual(self, "hover")
        end)
        button:SetScript("OnLeave", function(self)
            SetRowHover(row, false)
            SetBlacklistActionButtonVisual(self, "normal")
        end)
        button:SetScript("OnMouseDown", function(self, mouseButton)
            if mouseButton == "LeftButton" then
                SetBlacklistActionButtonVisual(self, "pressed")
            end
        end)
        button:SetScript("OnMouseUp", function(self)
            if self.IsMouseOver and self:IsMouseOver() then
                SetBlacklistActionButtonVisual(self, "hover")
            else
                SetBlacklistActionButtonVisual(self, "normal")
            end
        end)
    end

    return row
end

local function EnsureBlacklistRows(widgets, count)
    widgets.blacklistRows = widgets.blacklistRows or {}
    for index = #widgets.blacklistRows + 1, count do
        widgets.blacklistRows[index] = CreateBlacklistRow(widgets.blacklistContent)
    end
end

local function ApplyBlacklistRowLayout(row, layout, width)
    row:SetWidth(math.max(width or layout.totalWidth or 1, 1))
    local offsetX = ROW_COLUMN_OFFSET_X

    row.Name:ClearAllPoints()
    row.Name:SetPoint("LEFT", row, "LEFT", offsetX + layout.player.x + ROW_TEXT_INSET, 0)
    row.Name:SetSize(math.max(layout.player.width - ROW_TEXT_INSET * 2, 1), ROW_HEIGHT)
    row.NameHitBox:ClearAllPoints()
    row.NameHitBox:SetPoint("LEFT", row, "LEFT", offsetX + layout.player.x, 0)
    row.NameHitBox:SetSize(layout.player.width, ROW_HEIGHT)

    row.Reason:ClearAllPoints()
    row.Reason:SetPoint("CENTER", row, "LEFT", offsetX + layout.reason.x + layout.reason.width / 2, 0)
    local reasonBadgeWidth = math.min(math.max(layout.reason.width - ROW_TEXT_INSET * 2, REASON_BADGE_MIN_WIDTH), REASON_BADGE_MAX_WIDTH)
    row.Reason:SetSize(reasonBadgeWidth, REASON_BADGE_HEIGHT)

    row.NoteText:ClearAllPoints()
    row.NoteText:SetPoint("LEFT", row, "LEFT", offsetX + layout.note.x + ROW_TEXT_INSET, 0)
    row.NoteText:SetSize(math.max(layout.note.width - ROW_TEXT_INSET * 2, 1), ROW_HEIGHT)
    row.NoteHitBox:ClearAllPoints()
    row.NoteHitBox:SetPoint("LEFT", row, "LEFT", offsetX + layout.note.x, 0)
    row.NoteHitBox:SetSize(layout.note.width, ROW_HEIGHT)

    row.NoteBox:ClearAllPoints()
    row.NoteBox:SetPoint("LEFT", row, "LEFT", offsetX + layout.note.x + ROW_TEXT_INSET, 0)
    row.NoteBox:SetWidth(math.max(layout.note.width - ROW_TEXT_INSET * 2, 1))
    row.NoteBox:SetHeight(NOTE_INPUT_HEIGHT)

    row.DateFrame:ClearAllPoints()
    row.DateFrame:SetPoint("LEFT", row, "LEFT", offsetX + layout.updated.x, 0)
    row.DateFrame:SetSize(layout.updated.width, ROW_HEIGHT)

    local actionCenterX = offsetX + layout.action.x + layout.action.width / 2
    row.EditButton:ClearAllPoints()
    row.RemoveButton:ClearAllPoints()
    row.SaveButton:ClearAllPoints()
    row.CancelButton:ClearAllPoints()
    row.EditButton:SetPoint("RIGHT", row, "LEFT", actionCenterX - (ACTION_BUTTON_GAP / 2), 0)
    row.RemoveButton:SetPoint("LEFT", row, "LEFT", actionCenterX + (ACTION_BUTTON_GAP / 2), 0)
    row.SaveButton:SetPoint("RIGHT", row, "LEFT", actionCenterX - (ACTION_BUTTON_GAP / 2), 0)
    row.CancelButton:SetPoint("LEFT", row, "LEFT", actionCenterX + (ACTION_BUTTON_GAP / 2), 0)
end

local function SetSaveButtonEnabled(row, enabled)
    if not row then
        return
    end
    row.SaveButton:SetShown(true)
    SetBlacklistActionButtonEnabled(row.SaveButton, enabled == true)
end

local function BeginBlacklistRowEdit(owner, widgets, row, entry)
    widgets.blacklistEditingKey = entry.key
    widgets.blacklistEditingOriginalNote = entry.note or ""
    widgets.blacklistEditingDraft = entry.note or ""
    BlacklistTab.Refresh(owner, widgets)

    if row and row.NoteBox and row.mkBlacklistKey == entry.key then
        row.NoteBox:SetFocus()
        row.NoteBox:HighlightText()
    end
end

local function CancelBlacklistRowEdit(owner, widgets)
    widgets.blacklistEditingKey = nil
    widgets.blacklistEditingOriginalNote = nil
    widgets.blacklistEditingDraft = nil
    BlacklistTab.Refresh(owner, widgets)
end

local function SaveBlacklistRowEdit(owner, widgets)
    local key = widgets.blacklistEditingKey
    if not key then
        return
    end

    local nextNote = widgets.blacklistEditingDraft or ""
    local originalNote = widgets.blacklistEditingOriginalNote or ""
    widgets.blacklistEditingKey = nil
    widgets.blacklistEditingOriginalNote = nil
    widgets.blacklistEditingDraft = nil

    if nextNote ~= originalNote and owner and owner.SetBlacklistNote then
        owner:SetBlacklistNote(key, nextNote)
    else
        BlacklistTab.Refresh(owner, widgets)
    end
end

local function ShowRemoveConfirm(owner, entry)
    if not entry then
        return
    end

    local dialog = GetBlacklistRemoveDialog()
    if not dialog then
        return
    end

    dialog.blacklistOwner = owner
    dialog.blacklistKey = entry.key
    if dialog.Message then
        dialog.Message:SetText(TRANSLATE(REMOVE_DIALOG_MESSAGE_KEY))
    end
    LayoutBlacklistRemoveDialog(dialog)
    dialog:ClearAllPoints()
    dialog:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    dialog:Show()
    if dialog.Raise then
        dialog:Raise()
    end
end

local function RefreshBlacklistRow(owner, widgets, row, entry)
    row.mkBlacklistKey = entry.key
    local isEditing = widgets.blacklistEditingKey == entry.key
    row.Background:SetVertexColor(0, 0, 0, 0.22)
    SetBlacklistRowBackgroundAlpha(row, ROW_BACKGROUND_ALPHA)
    SetRowEditing(row, isEditing)
    SetRowHover(row, false)

    local displayName = entry.displayName or entry.name or entry.key or ""
    row.Name:SetText(displayName)
    row.NameHitBox:SetScript("OnEnter", function()
        SetRowHover(row, true)
        ShowBlacklistEntryTooltip(row.NameHitBox, entry)
    end)

    local reason = GetBlacklistEntryReason(entry)
    row.Reason.Label:SetText(GetReasonText(reason))
    SetBlacklistReasonBadgeStyle(row.Reason, reason)

    local note = entry.note or ""
    local noteText = note ~= "" and note or TRANSLATE("无备注")
    row.NoteText:SetText(noteText)
    row.NoteText:SetTextColor(note ~= "" and BODY_TEXT_COLOR[1] or SECONDARY_TEXT_COLOR[1], note ~= "" and BODY_TEXT_COLOR[2] or SECONDARY_TEXT_COLOR[2], note ~= "" and BODY_TEXT_COLOR[3] or SECONDARY_TEXT_COLOR[3], 1)
    row.NoteHitBox:SetScript("OnClick", function()
        BeginBlacklistRowEdit(owner, widgets, row, entry)
    end)
    row.NoteHitBox:SetScript("OnEnter", function()
        SetRowHover(row, true)
        ShowBlacklistEntryTooltip(row.NoteHitBox, entry)
    end)

    local updatedAt = entry.updatedAt or entry.addedAt
    row.Date:SetText(FormatBlacklistDateShort(updatedAt))
    row.DateFrame:SetScript("OnEnter", function()
        SetRowHover(row, true)
        ShowBlacklistEntryTooltip(row.DateFrame, entry)
    end)

    row.EditButton:SetScript("OnClick", function()
        if core.PlayPanelButtonClickSound then
            core.PlayPanelButtonClickSound()
        end
        BeginBlacklistRowEdit(owner, widgets, row, entry)
    end)
    row.RemoveButton:SetScript("OnClick", function()
        if core.PlayPanelButtonClickSound then
            core.PlayPanelButtonClickSound()
        end
        ShowRemoveConfirm(owner, entry)
    end)
    row.CancelButton:SetScript("OnClick", function()
        if core.PlayPanelButtonClickSound then
            core.PlayPanelButtonClickSound()
        end
        CancelBlacklistRowEdit(owner, widgets)
    end)
    row.SaveButton:SetScript("OnClick", function()
        if core.PlayPanelButtonClickSound then
            core.PlayPanelButtonClickSound()
        end
        if (widgets.blacklistEditingDraft or "") ~= (widgets.blacklistEditingOriginalNote or "") then
            SaveBlacklistRowEdit(owner, widgets)
        end
    end)

    row.NoteText:SetShown(not isEditing)
    row.NoteHitBox:SetShown(not isEditing)
    row.EditButton:SetShown(not isEditing)
    row.RemoveButton:SetShown(not isEditing)
    row.NoteBox:SetShown(isEditing)
    row.CancelButton:SetShown(isEditing)

    if isEditing then
        row.SaveButton:Show()
        local draft = widgets.blacklistEditingDraft or note
        if row.NoteBox:GetText() ~= draft then
            row.NoteBox:SetText(draft)
        end
        row.NoteBox.mkBlacklistKey = entry.key
        row.NoteBox:SetScript("OnTextChanged", function(self)
            if widgets.blacklistEditingKey ~= entry.key then
                return
            end
            widgets.blacklistEditingDraft = self:GetText() or ""
            SetSaveButtonEnabled(row, widgets.blacklistEditingDraft ~= (widgets.blacklistEditingOriginalNote or ""))
        end)
        row.NoteBox:SetScript("OnEscapePressed", function()
            CancelBlacklistRowEdit(owner, widgets)
        end)
        row.NoteBox:SetScript("OnEnterPressed", function()
            if widgets.blacklistEditingDraft ~= (widgets.blacklistEditingOriginalNote or "") then
                SaveBlacklistRowEdit(owner, widgets)
            else
                CancelBlacklistRowEdit(owner, widgets)
            end
        end)
        SetSaveButtonEnabled(row, (widgets.blacklistEditingDraft or "") ~= (widgets.blacklistEditingOriginalNote or ""))
    else
        row.NoteBox:ClearFocus()
        row.NoteBox:SetScript("OnTextChanged", nil)
        row.NoteBox:SetScript("OnEscapePressed", nil)
        row.NoteBox:SetScript("OnEnterPressed", nil)
        row.SaveButton:Hide()
        SetBlacklistActionButtonEnabled(row.SaveButton, true)
    end

    row:Show()
end

local function CompareBlacklistEntryByUpdated(a, b)
    local aTime = tonumber(a and (a.updatedAt or a.addedAt)) or 0
    local bTime = tonumber(b and (b.updatedAt or b.addedAt)) or 0
    if aTime ~= bTime then
        return aTime > bTime
    end
    return tostring(a and (a.displayName or a.name or a.key) or "") < tostring(b and (b.displayName or b.name or b.key) or "")
end

local function BuildBlacklistEntries(owner, widgets)
    local sourceEntries = owner.GetBlacklistEntries and owner:GetBlacklistEntries() or {}
    local entries = {}

    for _, entry in ipairs(sourceEntries) do
        local reason = GetBlacklistEntryReason(entry)
        entry.mkBlacklistReason = reason
        entries[#entries + 1] = entry
    end

    if widgets and widgets.blacklistSortKey == BLACKLIST_CATEGORY_SORT_KEY then
        table.sort(entries, function(a, b)
            local aReason = a and a.mkBlacklistReason or "other"
            local bReason = b and b.mkBlacklistReason or "other"
            local aOrder = REASON_SORT_ORDER[aReason] or 999
            local bOrder = REASON_SORT_ORDER[bReason] or 999
            if aOrder ~= bOrder then
                return aOrder < bOrder
            end
            return CompareBlacklistEntryByUpdated(a, b)
        end)
    else
        table.sort(entries, CompareBlacklistEntryByUpdated)
    end

    return entries
end

local function RenderVisibleBlacklistRows(owner, widgets)
    if not (widgets and widgets.blacklistScrollFrame and widgets.blacklistContent) then
        return
    end

    local entries = widgets.blacklistEntries or {}
    local totalRows = #entries
    local viewportHeight = math.max(widgets.blacklistScrollFrame:GetHeight() or ROW_HEIGHT, ROW_HEIGHT)
    local visibleCount = math.min(totalRows, math.max(1, math.ceil(viewportHeight / ROW_HEIGHT) + 2))
    EnsureBlacklistRows(widgets, visibleCount)

    local scrollOffset = widgets.blacklistScrollFrame:GetVerticalScroll() or 0
    local firstIndex = math.floor(scrollOffset / ROW_HEIGHT) + 1
    local layout = widgets.blacklistColumnLayout or GetBlacklistColumnLayout(widgets.blacklistScrollFrame:GetWidth() or 1)
    local width = math.max(widgets.blacklistScrollFrame:GetWidth() or layout.totalWidth or 1, layout.totalWidth or 1)

    for poolIndex, row in ipairs(widgets.blacklistRows or {}) do
        local entryIndex = firstIndex + poolIndex - 1
        local entry = entries[entryIndex]
        if entry and poolIndex <= visibleCount then
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", widgets.blacklistContent, "TOPLEFT", 0, -((entryIndex - 1) * ROW_HEIGHT))
            row:SetPoint("TOPRIGHT", widgets.blacklistContent, "TOPRIGHT", 0, -((entryIndex - 1) * ROW_HEIGHT))
            ApplyBlacklistRowLayout(row, layout, width)
            RefreshBlacklistRow(owner, widgets, row, entry, entryIndex)
        else
            row:Hide()
        end
    end
end

local function UpdateBlacklistScrollState(widgets, totalRows)
    if not (widgets and widgets.blacklistScrollFrame and widgets.blacklistContent and widgets.blacklistScrollBar) then
        return
    end

    local scrollFrame = widgets.blacklistScrollFrame
    local content = widgets.blacklistContent
    local scrollBar = widgets.blacklistScrollBar
    local viewportHeight = math.max(scrollFrame:GetHeight() or 1, 1)
    local contentHeight = math.max(totalRows * ROW_HEIGHT + SCROLL_BOTTOM_INSET, viewportHeight)
    content:SetWidth(math.max(scrollFrame:GetWidth() or 1, 1))
    content:SetHeight(contentHeight)

    local maxScroll = math.max(contentHeight - viewportHeight, 0)
    if scrollBar.mkSetScrollRange then
        scrollBar.mkSetScrollRange(0, maxScroll)
    elseif scrollBar.SetMinMaxValues then
        scrollBar:SetMinMaxValues(0, maxScroll)
    end
    if maxScroll > 0 then
        scrollBar:Show()
    else
        if scrollBar.mkSetScrollValue then
            scrollBar.mkSetScrollValue(0)
        elseif scrollBar.SetValue then
            scrollBar:SetValue(0)
        end
        scrollBar:Hide()
    end

    local current = scrollBar.mkGetScrollValue and scrollBar.mkGetScrollValue()
        or scrollBar.GetValue and scrollBar:GetValue()
        or 0
    current = math.min(current or 0, maxScroll)
    if scrollBar.mkSetScrollValue then
        scrollBar.mkSetScrollValue(current)
    elseif scrollBar.SetValue then
        scrollBar:SetValue(current)
    end
    scrollFrame:SetVerticalScroll(current)
end

local function ApplyBlacklistHeaderLayout(widgets)
    local header = widgets and widgets.blacklistColumnHeader
    if not header then
        return
    end

    local width = widgets.blacklistScrollFrame and widgets.blacklistScrollFrame:GetWidth() or header:GetWidth()
    width = math.max((width or 1) - 12, 1)
    local layout = GetBlacklistColumnLayout(width)
    widgets.blacklistColumnLayout = layout
    ApplyBlacklistColumnFrame(header, layout)
    header.blacklistSortKey = widgets.blacklistSortKey or BLACKLIST_DEFAULT_SORT_KEY
    SetHeaderSortTextState(header)

    if header.gapAccents then
        for _, accent in ipairs(header.gapAccents) do
            accent:Hide()
        end
    end
    header.gapAccents = {
        AddBlacklistHeaderAccentAt(header, layout.player.x + layout.player.width),
        AddBlacklistHeaderAccentAt(header, layout.reason.x + layout.reason.width),
        AddBlacklistHeaderAccentAt(header, layout.note.x + layout.note.width),
        AddBlacklistHeaderAccentAt(header, layout.updated.x + layout.updated.width),
    }
end

local function AddColumnHeader(panel, widgets)
    local background = panel:CreateTexture(nil, "BACKGROUND", nil, 1)
    background:SetPoint("TOPLEFT", panel, "TOPLEFT", 0, HEADER_TOP_OFFSET)
    background:SetPoint("BOTTOMRIGHT", panel, "TOPRIGHT", 0, HEADER_BOTTOM_OFFSET)
    TrySetAtlas(background, "housefinder_header-bg-gradient", false)
    panel.HeaderBackground = background

    local header = CreateFrame("Frame", nil, panel)
    header:SetPoint("TOPLEFT", background, "TOPLEFT", HEADER_CONTENT_INSET_X, HEADER_CONTENT_OFFSET_Y)
    header:SetPoint("BOTTOMRIGHT", background, "BOTTOMRIGHT", -HEADER_CONTENT_INSET_X, HEADER_CONTENT_OFFSET_Y)
    header:SetHeight(HEADER_HEIGHT)
    header:SetFrameLevel(panel:GetFrameLevel() + 3)
    header.blacklistWidgets = widgets

    header.player = CreateHeaderCell(header, TRANSLATE("玩家"))
    header.reason = CreateHeaderCell(header, TRANSLATE("类别"), BLACKLIST_CATEGORY_SORT_KEY)
    header.note = CreateHeaderCell(header, TRANSLATE("备注"))
    header.updated = CreateHeaderCell(header, TRANSLATE("更新时间"), BLACKLIST_DEFAULT_SORT_KEY)
    header.action = CreateHeaderCell(header, TRANSLATE("操作"))

    panel.ColumnHeader = header
    return header
end

local function UpdateBlacklistEmptyState(widgets, entryCount)
    local empty = widgets and widgets.blacklistEmpty
    if not empty then
        return
    end

    if entryCount > 0 then
        empty:Hide()
        return
    end

    empty:SetText(TRANSLATE("暂无黑名单记录"))
    empty:Show()
end

function BlacklistTab.BuildSnapshot(owner)
    local parts = {}
    for _, entry in ipairs(owner.GetBlacklistEntries and owner:GetBlacklistEntries() or {}) do
        parts[#parts + 1] = table.concat({
            SnapshotValue(entry.key),
            SnapshotValue(entry.displayName),
            SnapshotValue(entry.note),
            SnapshotValue(entry.source),
            SnapshotValue(entry.updatedAt),
        }, "~")
    end
    return table.concat(parts, "||")
end

function BlacklistTab.Refresh(owner, widgets, refreshSource)
    if not (widgets and widgets.blacklistContent and widgets.blacklistRows) then
        return
    end

    widgets.blacklistOwner = owner
    if refreshSource == "BuildSelectedTab" or not widgets.blacklistSortKey then
        widgets.blacklistSortKey = BLACKLIST_DEFAULT_SORT_KEY
        widgets.blacklistEditingKey = nil
        widgets.blacklistEditingOriginalNote = nil
        widgets.blacklistEditingDraft = nil
    end
    local entries = BuildBlacklistEntries(owner, widgets)
    widgets.blacklistEntries = entries

    ApplyBlacklistHeaderLayout(widgets)
    UpdateBlacklistEmptyState(widgets, #entries)
    UpdateBlacklistScrollState(widgets, #entries)
    RenderVisibleBlacklistRows(owner, widgets)
end

function BlacklistTab.Build(owner, tabGroup)
    local container = CreateFrame("Frame", nil, UIParent)
    container.mkFillDecorFrame = true
    container.mkDecorFrameInsetLeft = PANEL_INSET_LEFT
    container.mkDecorFrameInsetTop = PANEL_INSET_TOP
    container.mkDecorFrameInsetRight = PANEL_INSET_RIGHT
    container.mkDecorFrameInsetBottom = PANEL_INSET_BOTTOM
    tabGroup:AddChild(container)

    local panel = CreateFrame("Frame", nil, container, "BackdropTemplate")
    panel:SetPoint("TOPLEFT", container, "TOPLEFT", 0, 0)
    panel:SetPoint("BOTTOMRIGHT", container, "BOTTOMRIGHT", 0, 0)

    local background = panel:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(panel)
    background:SetTexture("Interface\\Buttons\\WHITE8X8")
    background:SetVertexColor(0, 0, 0, 1)
    background:Hide()
    panel.Background = background

    local widgets = {
        blacklistOwner = owner,
        blacklistSortKey = BLACKLIST_DEFAULT_SORT_KEY,
    }
    local columnHeader = AddColumnHeader(panel, widgets)

    local scrollFrame, content, scrollBar = CreateBlacklistScrollContainer(panel)
    scrollFrame:SetPoint("TOPLEFT", panel.HeaderBackground, "BOTTOMLEFT", 0, -4)
    scrollFrame:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -(SCROLLBAR_WIDTH + SCROLLBAR_GAP + SCROLLBAR_RIGHT_INSET), SCROLL_BOTTOM_INSET)
    scrollBar:SetPoint("TOPLEFT", scrollFrame, "TOPRIGHT", SCROLLBAR_GAP, -2)
    scrollBar:SetPoint("BOTTOMLEFT", scrollFrame, "BOTTOMRIGHT", SCROLLBAR_GAP, 2)
    if scrollFrame.SetClipsChildren then
        scrollFrame:SetClipsChildren(true)
    end

    local empty = panel:CreateFontString(nil, "OVERLAY", "GameFontDisable")
    empty:SetPoint("CENTER", scrollFrame, "CENTER", 0, 0)
    empty:SetWidth(520)
    empty:SetJustifyH("CENTER")
    empty:SetJustifyV("MIDDLE")
    if core.ApplyPanelEmptyPromptStyle then
        core.ApplyPanelEmptyPromptStyle(empty)
    end

    local topRightFiligree = panel:CreateTexture(nil, "ARTWORK", nil, 5)
    topRightFiligree:SetPoint("TOPRIGHT", panel, "TOPRIGHT", TOP_RIGHT_FILIGREE_OFFSET_X, TOP_RIGHT_FILIGREE_OFFSET_Y)
    TrySetAtlas(topRightFiligree, "housing-dashboard-filigree-wood-small-TR", true)

    local bottomRightFiligree = panel:CreateTexture(nil, "ARTWORK", nil, 5)
    bottomRightFiligree:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", BOTTOM_RIGHT_FILIGREE_OFFSET_X, BOTTOM_RIGHT_FILIGREE_OFFSET_Y)
    TrySetAtlas(bottomRightFiligree, "housing-dashboard-filigree-wood-small-BR", true)

    widgets.tabGroup = owner.widgets and owner.widgets.tabGroup or tabGroup
    widgets.root = container
    widgets.blacklistPanel = panel
    widgets.blacklistColumnHeader = columnHeader
    widgets.blacklistScrollFrame = scrollFrame
    widgets.blacklistContent = content
    widgets.blacklistScrollBar = scrollBar
    widgets.blacklistEmpty = empty
    widgets.blacklistRows = {}
    widgets.skipApplyFontToFrame = true

    scrollFrame.mkOnScroll = function()
        RenderVisibleBlacklistRows(owner, widgets)
    end
    scrollFrame.mkOnSizeChanged = function()
        BlacklistTab.Refresh(owner, widgets)
    end

    owner.widgets = widgets
    owner.tabViews = owner.tabViews or {}
    owner.tabViews.blacklist = widgets

    if core.ApplyThemeToTabGroup then
        core.ApplyThemeToTabGroup(tabGroup)
    end
    if core.ApplyFontToFrame then
        core.ApplyFontToFrame(container)
    end

    container:Show()
    BlacklistTab.Refresh(owner, widgets)
end
