local ADDON_NAME, namespace = ...
local GuildPing = namespace.GuildPing
local LOGO_TEXTURE = "Interface\\AddOns\\" .. ADDON_NAME .. "\\Media\\GuildPing-Logo.png"

-- Guild Ping's visual system: Discord blue for actions, guild green for active
-- choices, and a quiet midnight surface so the mention colors stay legible.
local DISCORD_BLUE = CreateColor(0.345, 0.396, 0.949, 1)
local GUILD_GREEN = CreateColor(0.196, 0.659, 0.322, 1)
local ROLE_COLOR = CreateColor(0.663, 0.482, 1, 1)
local USER_COLOR = CreateColor(0.345, 0.745, 0.949, 1)
local MUTED_COLOR = CreateColor(0.725, 0.745, 0.800, 1)
local TEXT_COLOR = CreateColor(0.925, 0.937, 0.965, 1)
local UPDATE_RED = CreateColor(1, 0.255, 0.255, 1)
local WARNING_GOLD = CreateColor(1, 0.710, 0.220, 1)
local ROW_HEIGHT = 30
local ROW_BACKDROP = {
	bgFile = "Interface\\Buttons\\WHITE8X8",
	edgeFile = "Interface\\Buttons\\WHITE8X8",
	edgeSize = 1,
}
local WINDOW_BACKDROP = {
	bgFile = "Interface\\Buttons\\WHITE8X8",
	edgeFile = "Interface\\Buttons\\WHITE8X8",
	edgeSize = 1,
}

local function SetTitle(frame, title)
	if frame.TitleText then
		frame.TitleText:SetText(title)
	elseif frame.title then
		frame.title:SetText(title)
	end
end

local function MakeDraggable(frame, dragHandle)
	dragHandle = dragHandle or frame
	frame:SetMovable(true)
	frame:SetClampedToScreen(true)
	frame:EnableMouse(true)
	dragHandle:EnableMouse(true)
	dragHandle:RegisterForDrag("LeftButton")

	local position = frame.positionKey and GuildPing:GetWindowPosition(frame.positionKey)
	if position then
		frame:ClearAllPoints()
		frame:SetPoint(position.point, UIParent, position.relativePoint, position.x, position.y)
	end

	dragHandle:SetScript("OnDragStart", function()
		frame:StartMoving()
	end)
	dragHandle:SetScript("OnDragStop", function()
		frame:StopMovingOrSizing()
		GuildPing:SaveWindowPosition(frame.positionKey, frame)
	end)
end

local function SetButtonVisual(button, state)
	if not button:IsEnabled() then
		button:SetBackdropColor(0.075, 0.085, 0.115, 0.82)
		button:SetBackdropBorderColor(0.20, 0.22, 0.29, 0.85)
		button.label:SetTextColor(0.43, 0.45, 0.51, 1)
		return
	end

	if button.variant == "primary" then
		if state == "pressed" then
			button:SetBackdropColor(0.245, 0.286, 0.760, 1)
		elseif state == "hover" then
			button:SetBackdropColor(0.410, 0.459, 1.000, 1)
		else
			button:SetBackdropColor(DISCORD_BLUE:GetRGBA())
		end
		button:SetBackdropBorderColor(0.54, 0.59, 1, 1)
		button.label:SetTextColor(1, 1, 1, 1)
	elseif button.variant == "danger" then
		button:SetBackdropColor(state == "hover" and 0.62 or 0.43, 0.10, 0.14, 1)
		button:SetBackdropBorderColor(0.86, 0.27, 0.31, 1)
		button.label:SetTextColor(1, 0.92, 0.92, 1)
	else
		button:SetBackdropColor(state == "hover" and 0.145 or 0.098, state == "hover" and 0.169 or 0.118, state == "hover" and 0.239 or 0.165, 1)
		button:SetBackdropBorderColor(state == "hover" and 0.345 or 0.235, state == "hover" and 0.396 or 0.267, state == "hover" and 0.949 or 0.345, 1)
		button.label:SetTextColor(TEXT_COLOR:GetRGB())
	end
end

local function CreateButton(parent, text, width, height, variant)
	local button = CreateFrame("Button", nil, parent, "BackdropTemplate")
	button:SetSize(width or 110, height or 24)
	button:SetBackdrop(ROW_BACKDROP)
	button.variant = variant or "secondary"
	button.label = button:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
	button.label:SetPoint("CENTER", 0, 0)
	button.label:SetText(text or "")
	button.label:SetWordWrap(false)
	button.SetText = function(self, value)
		self.label:SetText(value or "")
	end
	button.GetText = function(self)
		return self.label:GetText()
	end
	button.SetVariant = function(self, newVariant)
		self.variant = newVariant or "secondary"
		SetButtonVisual(self)
	end
	button:SetScript("OnEnter", function(self)
		SetButtonVisual(self, "hover")
	end)
	button:SetScript("OnLeave", function(self)
		SetButtonVisual(self)
	end)
	button:SetScript("OnMouseDown", function(self)
		SetButtonVisual(self, "pressed")
		self.label:SetPoint("CENTER", 0, -1)
	end)
	button:SetScript("OnMouseUp", function(self)
		self.label:SetPoint("CENTER", 0, 0)
		SetButtonVisual(self, self:IsMouseOver() and "hover" or nil)
	end)
	button:SetScript("OnEnable", function(self)
		SetButtonVisual(self)
	end)
	button:SetScript("OnDisable", function(self)
		SetButtonVisual(self)
	end)
	SetButtonVisual(button)
	return button
end

local function AddTooltip(region, title, message, anchor)
	region:HookScript("OnEnter", function(self)
		GameTooltip:SetOwner(self, anchor or "ANCHOR_RIGHT")
		GameTooltip:AddLine(title, 1, 1, 1)
		GameTooltip:AddLine(message, 0.80, 0.82, 0.88, true)
		GameTooltip:Show()
	end)
	region:HookScript("OnLeave", function()
		GameTooltip:Hide()
	end)
end

local function SetChoiceButtonSelected(button, selected)
	button.selected = selected
	if selected then
		button:SetBackdropColor(0.125, 0.460, 0.225, 1)
		button:SetBackdropBorderColor(GUILD_GREEN:GetRGB())
		button.label:SetTextColor(1, 1, 1, 1)
	else
		button:SetBackdropColor(0.098, 0.118, 0.165, 1)
		button:SetBackdropBorderColor(0.235, 0.267, 0.345, 1)
		button.label:SetTextColor(MUTED_COLOR:GetRGB())
	end
end

local function CreateChoiceButton(parent, text, width)
	local button = CreateFrame("Button", nil, parent, "BackdropTemplate")
	button:SetSize(width, 27)
	button:SetBackdrop(ROW_BACKDROP)
	button.label = button:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
	button.label:SetText(text)
	button.label:SetPoint("CENTER")
	button:SetScript("OnEnter", function(self)
		if not self.selected then
			self:SetBackdropColor(0.145, 0.169, 0.239, 1)
			self:SetBackdropBorderColor(DISCORD_BLUE:GetRGB())
		end
	end)
	button:SetScript("OnLeave", function(self)
		SetChoiceButtonSelected(self, self.selected)
	end)
	SetChoiceButtonSelected(button, false)
	return button
end

local function CreateWindow(name)
	local frame = CreateFrame("Frame", name, UIParent, "BackdropTemplate")
	frame.isGuildPingWindow = true
	frame.positionKey = name
	frame:SetBackdrop(WINDOW_BACKDROP)
	frame:SetBackdropColor(0.063, 0.075, 0.106, 0.985)
	frame:SetBackdropBorderColor(0.235, 0.267, 0.345, 1)
	frame:SetToplevel(true)

	frame.header = CreateFrame("Frame", nil, frame, "BackdropTemplate")
	frame.header:SetPoint("TOPLEFT", 1, -1)
	frame.header:SetPoint("TOPRIGHT", -1, -1)
	frame.header:SetHeight(36)
	frame.header:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" })
	frame.header:SetBackdropColor(0.098, 0.118, 0.165, 1)

	frame.headerBlue = frame.header:CreateTexture(nil, "ARTWORK")
	frame.headerBlue:SetColorTexture(DISCORD_BLUE:GetRGBA())
	frame.headerBlue:SetPoint("BOTTOMLEFT")
	frame.headerBlue:SetPoint("RIGHT", frame.header, "CENTER", 58, 0)
	frame.headerBlue:SetHeight(3)

	frame.headerGreen = frame.header:CreateTexture(nil, "ARTWORK")
	frame.headerGreen:SetColorTexture(GUILD_GREEN:GetRGBA())
	frame.headerGreen:SetPoint("BOTTOMRIGHT")
	frame.headerGreen:SetPoint("LEFT", frame.headerBlue, "RIGHT")
	frame.headerGreen:SetHeight(3)

	frame.TitleIcon = frame.header:CreateTexture(nil, "OVERLAY")
	frame.TitleIcon:SetTexture(LOGO_TEXTURE)
	frame.TitleIcon:SetSize(26, 26)
	frame.TitleIcon:SetPoint("LEFT", 9, 1)

	frame.TitleText = frame.header:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
	frame.TitleText:SetPoint("LEFT", frame.TitleIcon, "RIGHT", 6, 1)
	frame.TitleText:SetPoint("RIGHT", -42, 1)
	frame.TitleText:SetJustifyH("LEFT")
	frame.TitleText:SetTextColor(TEXT_COLOR:GetRGB())

	frame.closeButton = CreateButton(frame.header, "×", 25, 25)
	frame.closeButton:SetPoint("RIGHT", -7, 1)
	frame.closeButton.label:SetFontObject("GameFontNormalLarge")
	frame.closeButton:SetScript("OnClick", function()
		frame:Hide()
	end)

	frame.contentSurface = frame:CreateTexture(nil, "BACKGROUND")
	frame.contentSurface:SetColorTexture(0.078, 0.090, 0.125, 0.78)
	frame.contentSurface:SetPoint("TOPLEFT", 9, -44)
	frame.contentSurface:SetPoint("BOTTOMRIGHT", -9, 9)

	if name and UISpecialFrames then
		table.insert(UISpecialFrames, name)
	end
	return frame
end

local function CloseOwnerWindow(region)
	local owner = region
	while owner do
		if owner.isGuildPingWindow then
			owner:Hide()
			return true
		end
		owner = owner.GetParent and owner:GetParent() or nil
	end
	return false
end

local EXPORT_POPUP_KEY = "GUILD_PING_EXPORT"
local SHARE_POPUP_KEY = "GUILD_PING_SHARE"

local function EnsureExportPopup()
	if StaticPopupDialogs[EXPORT_POPUP_KEY] then
		return
	end

	StaticPopupDialogs[EXPORT_POPUP_KEY] = {
		text = "Generated %s mapping(s), %s characters. Press Ctrl+C to copy:",
		button1 = CLOSE,
		hasEditBox = true,
		editBoxWidth = 420,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnShow = function(dialog)
			-- StaticPopup positions its edit box after OnShow, so populate it on
			-- the next frame. Keep a GuildPing fallback because some client builds
			-- assign dialog.data after invoking this callback.
			C_Timer.After(0, function()
				if not dialog:IsShown() then
					return
				end
				local editBox = dialog.GetEditBox and dialog:GetEditBox()
					or dialog.editBox
					or _G[dialog:GetName() .. "EditBox"]
				if not editBox then
					GuildPing:Print("Could not create the export copy field.")
					return
				end
				local payload = tostring(dialog.data or GuildPing.pendingExportPayload or "")
				-- On current Retail, zero can behave as a literal zero-character
				-- limit on StaticPopup edit boxes instead of meaning "unlimited."
				editBox:SetMaxLetters(math.max(50000, #payload))
				editBox:SetText(payload)
				if editBox:GetText() ~= payload then
					editBox:SetText("")
					editBox:Insert(payload)
				end
				if editBox:GetText() ~= payload then
					GuildPing:Print(string.format("The export was generated (%d characters), but WoW rejected the copy field text.", #payload))
				end
				editBox:SetFocus()
				editBox:HighlightText(0, #payload)
			end)
		end,
		OnHide = function(dialog)
			local editBox = dialog.GetEditBox and dialog:GetEditBox() or dialog.editBox
			if editBox then
				editBox:SetText("")
				editBox:ClearFocus()
			end
		end,
		EditBoxOnEscapePressed = function(editBox)
			editBox:GetParent():Hide()
		end,
		EditBoxOnEnterPressed = function(editBox)
			editBox:HighlightText()
		end,
		OnAccept = function() end,
	}
end

local function EnsureSharePopup()
	if StaticPopupDialogs[SHARE_POPUP_KEY] then
		return
	end

	StaticPopupDialogs[SHARE_POPUP_KEY] = {
		text = "Share %s Guild Ping mapping(s) with your guild? Recipients must review and approve all changes.",
		button1 = "Share",
		button2 = CANCEL,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnAccept = function()
			local result, errorMessage = GuildPing:SendGuildMappings()
			if result then
				GuildPing:Print(string.format(
					"Shared %d mapping(s) with the guild for review.",
					result.mappingCount
				))
				if GuildPing.manager then
					GuildPing.manager.statusText:SetText("Guild share sent. Recipients must review before applying it.")
				end
			else
				GuildPing:Print(errorMessage)
			end
		end,
	}
end

local function CreateLabel(parent, text, template)
	template = template or "GameFontNormal"
	local label = parent:CreateFontString(nil, "OVERLAY", template)
	label:SetText(text or "")
	label:SetJustifyH("LEFT")
	if template:find("Disable") then
		label:SetTextColor(0.50, 0.53, 0.60, 1)
	elseif template:find("Normal") then
		label:SetTextColor(TEXT_COLOR:GetRGB())
	end
	return label
end

local function CreateInput(parent, width, instructions, maxLetters)
	local input = CreateFrame("EditBox", nil, parent, "BackdropTemplate")
	input:SetSize(width, 27)
	input:SetBackdrop(ROW_BACKDROP)
	input:SetBackdropColor(0.047, 0.055, 0.078, 1)
	input:SetBackdropBorderColor(0.235, 0.267, 0.345, 1)
	input:SetTextInsets(9, 9, 0, 0)
	input:SetFontObject(GameFontHighlight)
	input:SetTextColor(TEXT_COLOR:GetRGB())
	input:SetAutoFocus(false)
	input:SetMaxLetters(maxLetters or 80)
	input.Instructions = input:CreateFontString(nil, "OVERLAY", "GameFontDisable")
	input.Instructions:SetPoint("LEFT", 9, 0)
	input.Instructions:SetPoint("RIGHT", -9, 0)
	input.Instructions:SetJustifyH("LEFT")
	input.Instructions:SetText(instructions or "")
	input.UpdateInstructions = function(self)
		local hasText = (self:GetText() or "") ~= ""
		self.Instructions:SetShown(not hasText and not self:HasFocus())
	end
	input:SetScript("OnEscapePressed", function(self)
		if not CloseOwnerWindow(self) then
			self:ClearFocus()
		end
	end)
	input:HookScript("OnTextChanged", function(self)
		self:UpdateInstructions()
	end)
	input:HookScript("OnEditFocusGained", function(self)
		self:UpdateInstructions()
		self:SetBackdropBorderColor(DISCORD_BLUE:GetRGB())
	end)
	input:HookScript("OnEditFocusLost", function(self)
		self:UpdateInstructions()
		self:SetBackdropBorderColor(0.235, 0.267, 0.345, 1)
	end)
	input:UpdateInstructions()
	return input
end

local function CreateTargetRow(parent, width)
	local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
	row:SetSize(width, ROW_HEIGHT - 2)
	row:SetBackdrop(ROW_BACKDROP)
	row:SetBackdropColor(0.047, 0.055, 0.078, 0.92)
	row:SetBackdropBorderColor(0.180, 0.204, 0.275, 0.95)

	row.typeText = CreateLabel(row, "", "GameFontNormalSmall")
	row.typeText:SetPoint("LEFT", 9, 0)
	row.typeText:SetWidth(54)

	row.nameText = CreateLabel(row, "", "GameFontHighlight")
	row.nameText:SetPoint("LEFT", 70, 0)
	row.nameText:SetWidth(width - 160)
	row.nameText:SetWordWrap(false)

	row.aliasText = CreateLabel(row, "", "GameFontNormalSmall")
	row.aliasText:SetPoint("RIGHT", -9, 0)
	row.aliasText:SetWidth(85)
	row.aliasText:SetJustifyH("RIGHT")
	row.aliasText:SetTextColor(MUTED_COLOR:GetRGB())

	row:SetScript("OnEnter", function(self)
		self:SetBackdropColor(0.145, 0.169, 0.239, 1)
		self:SetBackdropBorderColor(DISCORD_BLUE:GetRGB())
	end)
	row:SetScript("OnLeave", function(self)
		if self.selected then
			self:SetBackdropColor(0.100, 0.345, 0.180, 1)
			self:SetBackdropBorderColor(GUILD_GREEN:GetRGB())
		else
			self:SetBackdropColor(0.047, 0.055, 0.078, 0.92)
			self:SetBackdropBorderColor(0.180, 0.204, 0.275, 0.95)
		end
	end)
	return row
end

local function PopulateTargetRow(row, target)
	row.target = target
	if not target then
		row:Hide()
		return
	end

	local isRole = target.type == "ROLE"
	row.typeText:SetText(isRole and "ROLE" or "PERSON")
	row.typeText:SetTextColor((isRole and ROLE_COLOR or USER_COLOR):GetRGB())
	row.nameText:SetText(target.displayName or target.alias)
	row.aliasText:SetText("@" .. target.alias)
	row:Show()
end

local function SetTargetRowSelected(row, selected)
	row.selected = not not selected
	if not row.target then
		return
	end
	if row.selected then
		row:SetBackdropColor(0.100, 0.345, 0.180, 1)
		row:SetBackdropBorderColor(GUILD_GREEN:GetRGB())
	else
		row:SetBackdropColor(0.047, 0.055, 0.078, 0.92)
		row:SetBackdropBorderColor(0.180, 0.204, 0.275, 0.95)
	end
end

local function CreateImportPreviewRow(parent, width)
	local row = CreateFrame("Frame", nil, parent, "BackdropTemplate")
	row:SetSize(width, 30)
	row:SetBackdrop(ROW_BACKDROP)
	row:SetBackdropColor(0.047, 0.055, 0.078, 0.92)
	row:SetBackdropBorderColor(0.180, 0.204, 0.275, 0.95)

	row.actionText = CreateLabel(row, "", "GameFontNormalSmall")
	row.actionText:SetPoint("LEFT", 9, 0)
	row.actionText:SetWidth(82)

	row.mappingText = CreateLabel(row, "", "GameFontHighlightSmall")
	row.mappingText:SetPoint("LEFT", 92, 0)
	row.mappingText:SetPoint("RIGHT", -9, 0)
	row.mappingText:SetWordWrap(false)
	return row
end

local function PopulateImportPreviewRow(row, entry)
	if not entry or not entry.target then
		row:Hide()
		return
	end

	local target = entry.target
	local actionColor = entry.action == "ADD" and GUILD_GREEN
		or entry.action == "UPDATE" and WARNING_GOLD
		or MUTED_COLOR
	local typeLabel = target.type == "ROLE" and "Role" or "Person"
	local idSuffix = tostring(target.id or ""):sub(-4)
	local description = string.format(
		"@%s — %s (%s, ID …%s)",
		target.alias,
		target.displayName or target.alias,
		typeLabel,
		idSuffix
	)
	if entry.action == "UPDATE" and entry.current then
		local currentType = entry.current.type == "ROLE" and "Role" or "Person"
		description = description .. string.format(
			"  •  was %s (%s, …%s)",
			entry.current.displayName or entry.current.alias,
			currentType,
			tostring(entry.current.id or ""):sub(-4)
		)
	end

	row.actionText:SetText(entry.action)
	row.actionText:SetTextColor(actionColor:GetRGB())
	row.mappingText:SetText(description)
	row:Show()
end

local function FormatInboxTime(timestamp)
	if date and tonumber(timestamp) and tonumber(timestamp) > 0 then
		return date("%b %d, %H:%M", timestamp)
	end
	return "this session"
end

local function CreateShareInboxRow(parent, width)
	local row = CreateFrame("Frame", nil, parent, "BackdropTemplate")
	row:SetSize(width, 52)
	row:SetBackdrop(ROW_BACKDROP)
	row:SetBackdropColor(0.047, 0.055, 0.078, 0.92)
	row:SetBackdropBorderColor(0.180, 0.204, 0.275, 0.95)

	row.senderText = CreateLabel(row, "", "GameFontNormal")
	row.senderText:SetPoint("TOPLEFT", 10, -8)
	row.senderText:SetPoint("RIGHT", -186, 0)
	row.senderText:SetWordWrap(false)

	row.summaryText = CreateLabel(row, "", "GameFontHighlightSmall")
	row.summaryText:SetPoint("BOTTOMLEFT", 10, 8)
	row.summaryText:SetPoint("RIGHT", -186, 0)
	row.summaryText:SetTextColor(MUTED_COLOR:GetRGB())
	row.summaryText:SetWordWrap(false)

	row.dismissButton = CreateButton(row, "Dismiss", 76, 24)
	row.dismissButton:SetPoint("RIGHT", -8, 0)
	row.dismissButton:SetScript("OnClick", function()
		if row.entryID then
			GuildPing:DismissGuildShareInboxEntry(row.entryID)
		end
	end)

	row.reviewButton = CreateButton(row, "Review", 82, 24, "primary")
	row.reviewButton:SetPoint("RIGHT", row.dismissButton, "LEFT", -6, 0)
	row.reviewButton:SetScript("OnClick", function()
		if not row.entryID then
			return
		end
		local preview, errorMessage = GuildPing:BuildGuildShareInboxPreview(row.entryID)
		if preview then
			GuildPing:ShowImportPreview(preview)
		else
			GuildPing:Print(errorMessage)
		end
	end)
	return row
end

local function PopulateShareInboxRow(row, entry, preview, errorMessage)
	if not entry then
		row.entryID = nil
		row:Hide()
		return
	end

	row.entryID = entry.id
	local rankText = entry.senderRankName or "rank not found"
	row.senderText:SetText(string.format(
		"%s  •  %s  •  %s",
		entry.sender,
		rankText,
		FormatInboxTime(entry.receivedAt)
	))
	if preview then
		row.summaryText:SetText(string.format(
			"%d added  •  %d updated  •  %d unchanged  •  %d skipped",
			preview.added or 0,
			preview.updated or 0,
			preview.unchanged or 0,
			preview.skipped or 0
		))
		row.reviewButton:SetEnabled(true)
	else
		row.summaryText:SetText(errorMessage or "This share can no longer be reviewed.")
		row.reviewButton:SetEnabled(false)
	end
	row:Show()
end

-- Optional feature modules loaded after UI.lua can reuse Guild Ping's visual
-- language without duplicating the window, button, input, and color systems.
GuildPing.UIFactory = {
	CreateWindow = CreateWindow,
	MakeDraggable = MakeDraggable,
	SetTitle = SetTitle,
	CreateButton = CreateButton,
	CreateLabel = CreateLabel,
	CreateInput = CreateInput,
	RowBackdrop = ROW_BACKDROP,
	DiscordBlue = DISCORD_BLUE,
	GuildGreen = GUILD_GREEN,
	MutedColor = MUTED_COLOR,
	TextColor = TEXT_COLOR,
	WarningGold = WARNING_GOLD,
	UpdateRed = UPDATE_RED,
}

function GuildPing:CreatePicker()
	if self.picker then
		return self.picker
	end

	local picker = CreateWindow("GuildPingPickerFrame")
	picker:SetSize(470, 505)
	picker:SetPoint("CENTER", UIParent, "CENTER", -220, 40)
	picker:SetFrameStrata("DIALOG")
	MakeDraggable(picker)
	SetTitle(picker, "Guild Ping")

	picker.profileLabel = CreateLabel(picker, "", "GameFontHighlightSmall")
	picker.profileLabel:SetPoint("TOPLEFT", 18, -42)
	picker.profileLabel:SetPoint("RIGHT", -18, 0)
	picker.profileLabel:SetTextColor(MUTED_COLOR:GetRGB())

	picker.searchBox = CreateInput(picker, 430, "Search roles and people", 48)
	picker.searchBox:SetPoint("TOPLEFT", 20, -60)
	picker.searchBox:SetScript("OnTextChanged", function(self)
		self:UpdateInstructions()
		picker.scrollOffset = 0
		GuildPing:RefreshPicker()
	end)
	picker.searchBox:SetScript("OnEnterPressed", function(self)
		local results = GuildPing:GetSortedTargets(self:GetText())
		if results[1] then
			picker:Hide()
			GuildPing:ComposeTarget(results[1], nil, picker.preferredEditBox)
		end
	end)
	picker.searchBox:SetScript("OnEscapePressed", function()
		picker:Hide()
	end)

	picker.rows = {}
	for index = 1, 11 do
		local row = CreateTargetRow(picker, 416)
		row:SetPoint("TOPLEFT", 20, -96 - ((index - 1) * ROW_HEIGHT))
		row:SetScript("OnClick", function(self)
			if self.target then
				picker:Hide()
				GuildPing:ComposeTarget(self.target, nil, picker.preferredEditBox)
			end
		end)
		picker.rows[index] = row
	end

	picker.scrollBar = CreateFrame("Slider", nil, picker, "BackdropTemplate")
	picker.scrollBar:SetPoint("TOPLEFT", 442, -96)
	picker.scrollBar:SetSize(12, 328)
	picker.scrollBar:SetOrientation("VERTICAL")
	picker.scrollBar:SetMinMaxValues(0, 0)
	picker.scrollBar:SetValueStep(1)
	picker.scrollBar:SetObeyStepOnDrag(true)
	picker.scrollBar:SetBackdrop(ROW_BACKDROP)
	picker.scrollBar:SetBackdropColor(0.047, 0.055, 0.078, 0.92)
	picker.scrollBar:SetBackdropBorderColor(0.180, 0.204, 0.275, 0.95)
	picker.scrollBar.thumb = picker.scrollBar:CreateTexture(nil, "OVERLAY")
	picker.scrollBar.thumb:SetColorTexture(DISCORD_BLUE:GetRGBA())
	picker.scrollBar.thumb:SetSize(8, 42)
	picker.scrollBar:SetThumbTexture(picker.scrollBar.thumb)
	picker.scrollBar:SetScript("OnValueChanged", function(_, value)
		if picker.updatingScrollBar then
			return
		end
		local maximum = picker.scrollMaximum or 0
		local nextOffset = math.max(0, math.min(maximum, math.floor(value + 0.5)))
		if nextOffset ~= (picker.scrollOffset or 0) then
			picker.scrollOffset = nextOffset
			GuildPing:RefreshPicker()
		end
	end)

	picker.emptyText = CreateLabel(picker, "No mappings yet. Choose Manage to add your first Discord role or person.", "GameFontDisable")
	picker.emptyText:SetPoint("TOPLEFT", 45, -150)
	picker.emptyText:SetPoint("RIGHT", -45, 0)
	picker.emptyText:SetJustifyH("CENTER")
	picker.emptyText:SetWordWrap(true)

	picker.resultText = CreateLabel(picker, "", "GameFontHighlightSmall")
	picker.resultText:SetPoint("BOTTOMLEFT", 22, 55)
	picker.resultText:SetTextColor(MUTED_COLOR:GetRGB())

	picker.tipText = CreateLabel(picker, "Tip: type a configured @alias directly in guild chat.", "GameFontDisableSmall")
	picker.tipText:SetPoint("BOTTOMLEFT", 22, 35)

	picker.updateNotice = CreateLabel(picker, "", "GameFontNormalSmall")
	picker.updateNotice:SetPoint("BOTTOMLEFT", 22, 35)
	picker.updateNotice:SetPoint("BOTTOMRIGHT", -22, 35)
	picker.updateNotice:SetTextColor(UPDATE_RED:GetRGB())
	picker.updateNotice:SetJustifyH("LEFT")
	picker.updateNotice:Hide()

	picker.manageButton = CreateButton(picker, "Manage", 105, 25, "primary")
	picker.manageButton:SetPoint("BOTTOMRIGHT", -18, 9)
	picker.manageButton:SetScript("OnClick", function()
		picker:Hide()
		GuildPing:ShowManager()
	end)

	picker.helpButton = CreateButton(picker, "Help", 80, 24)
	picker.helpButton:SetPoint("RIGHT", picker.manageButton, "LEFT", -6, 0)
	picker.helpButton:SetScript("OnClick", function()
		GuildPing:ShowHelp()
	end)

	picker.inboxButton = CreateButton(picker, "Inbox", 110, 24)
	picker.inboxButton:SetPoint("BOTTOMLEFT", 18, 9)
	picker.inboxButton:SetScript("OnClick", function()
		GuildPing:ShowShareInbox()
	end)

	picker.gamesButton = CreateButton(picker, "Games", 110, 24)
	picker.gamesButton:SetPoint("LEFT", picker.inboxButton, "RIGHT", 6, 0)
	picker.gamesButton:SetScript("OnClick", function()
		if GuildPing.ShowGames then
			GuildPing:ShowGames()
		elseif GuildPing.ShowCheckers then
			GuildPing:ShowCheckers()
		end
	end)

	picker:SetScript("OnMouseWheel", function(_, delta)
		local total = #(GuildPing:GetSortedTargets(picker.searchBox:GetText()))
		local maximum = math.max(0, total - #picker.rows)
		picker.scrollOffset = math.max(0, math.min(maximum, (picker.scrollOffset or 0) - delta))
		GuildPing:RefreshPicker()
	end)
	picker:EnableMouseWheel(true)
	picker:SetScript("OnShow", function()
		GuildPing:RefreshPicker()
		C_Timer.After(0, function()
			if picker:IsShown() then
				picker.searchBox:SetFocus()
				picker.searchBox:HighlightText()
			end
		end)
	end)

	picker:Hide()
	self.picker = picker
	return picker
end

function GuildPing:RefreshPicker()
	if not self.picker then
		return
	end

	local picker = self.picker
	local _, _, profileLabel = self:GetProfile()
	local results = self:GetSortedTargets(picker.searchBox:GetText())
	local maximum = math.max(0, #results - #picker.rows)
	picker.scrollOffset = math.max(0, math.min(picker.scrollOffset or 0, maximum))
	picker.scrollMaximum = maximum
	picker.updatingScrollBar = true
	picker.scrollBar:SetMinMaxValues(0, maximum)
	picker.scrollBar:SetValue(picker.scrollOffset)
	picker.scrollBar:SetAlpha(maximum > 0 and 1 or 0.35)
	picker.scrollBar.thumb:SetShown(maximum > 0)
	picker.updatingScrollBar = false

	picker.profileLabel:SetText("Mappings for " .. profileLabel)
	picker.resultText:SetText(string.format("%d result%s%s", #results, #results == 1 and "" or "s", #results > #picker.rows and "  •  mouse wheel or drag the scroll bar" or ""))
	picker.emptyText:SetShown(#results == 0)

	for rowIndex, row in ipairs(picker.rows) do
		PopulateTargetRow(row, results[picker.scrollOffset + rowIndex])
	end
	self:RefreshVersionNotices()
	self:RefreshShareInboxIndicators()
	if self.RefreshGamesIndicators then
		self:RefreshGamesIndicators()
	elseif self.RefreshCheckersIndicators then
		self:RefreshCheckersIndicators()
	end
end

function GuildPing:ShowPicker(initialSearch)
	local picker = self:CreatePicker()
	picker.preferredEditBox = ChatFrameUtil and ChatFrameUtil.GetActiveWindow and ChatFrameUtil.GetActiveWindow() or nil
	picker.searchBox:SetText(initialSearch or "")
	picker.scrollOffset = 0
	picker:Show()
	self:RefreshPicker()
	self:SendVersionAnnouncement()
end

function GuildPing:CreateAutocomplete()
	if self.autocomplete then
		return self.autocomplete
	end

	local autocomplete = CreateFrame("Frame", "GuildPingAutocompleteFrame", UIParent, "BackdropTemplate")
	autocomplete:SetSize(330, 150)
	autocomplete:SetFrameStrata("TOOLTIP")
	autocomplete:SetBackdrop(ROW_BACKDROP)
	autocomplete:SetBackdropColor(0.063, 0.075, 0.106, 0.99)
	autocomplete:SetBackdropBorderColor(DISCORD_BLUE:GetRGB())
	autocomplete.blueRail = autocomplete:CreateTexture(nil, "ARTWORK")
	autocomplete.blueRail:SetColorTexture(DISCORD_BLUE:GetRGBA())
	autocomplete.blueRail:SetPoint("TOPLEFT", 1, -1)
	autocomplete.blueRail:SetPoint("TOP", autocomplete, "TOP", 45, -1)
	autocomplete.blueRail:SetHeight(2)
	autocomplete.greenRail = autocomplete:CreateTexture(nil, "ARTWORK")
	autocomplete.greenRail:SetColorTexture(GUILD_GREEN:GetRGBA())
	autocomplete.greenRail:SetPoint("TOPRIGHT", -1, -1)
	autocomplete.greenRail:SetPoint("LEFT", autocomplete.blueRail, "RIGHT")
	autocomplete.greenRail:SetHeight(2)

	autocomplete.rows = {}
	for index = 1, 5 do
		local row = CreateTargetRow(autocomplete, 314)
		row:SetHeight(24)
		row:SetPoint("TOPLEFT", 8, -7 - ((index - 1) * 25))
		row:SetScript("OnMouseDown", function(self)
			GuildPing:AcceptAutocompleteSelection(autocomplete.editBox, self.target)
		end)
		autocomplete.rows[index] = row
	end

	autocomplete.hint = CreateLabel(autocomplete, "Up/Down select  •  Enter/Tab insert", "GameFontDisableSmall")
	autocomplete.hint:SetPoint("BOTTOMLEFT", 10, 7)
	autocomplete:Hide()
	self.autocomplete = autocomplete
	return autocomplete
end

function GuildPing:RefreshAutocompleteRows()
	local autocomplete = self.autocomplete
	if not autocomplete then
		return
	end

	local results = autocomplete.results or {}
	local offset = autocomplete.scrollOffset or 0
	for rowIndex, row in ipairs(autocomplete.rows) do
		local resultIndex = offset + rowIndex
		local target = results[resultIndex]
		PopulateTargetRow(row, target)
		row.autocompleteIndex = target and resultIndex or nil
		SetTargetRowSelected(row, target and resultIndex == autocomplete.selectedIndex)
	end
end

function GuildPing:SetAutocompleteSelection(index)
	local autocomplete = self.autocomplete
	local results = autocomplete and autocomplete.results or nil
	if not results or #results == 0 then
		return false
	end

	if index < 1 then
		index = #results
	elseif index > #results then
		index = 1
	end
	autocomplete.selectedIndex = index

	local visibleRows = #autocomplete.rows
	local offset = autocomplete.scrollOffset or 0
	if index <= offset then
		offset = index - 1
	elseif index > offset + visibleRows then
		offset = index - visibleRows
	end
	autocomplete.scrollOffset = math.max(0, math.min(offset, math.max(0, #results - visibleRows)))
	self:RefreshAutocompleteRows()
	return true
end

function GuildPing:HandleAutocompleteArrow(editBox, key)
	key = tostring(key or ""):upper()
	local autocomplete = self.autocomplete
	if (key ~= "UP" and key ~= "DOWN")
		or not autocomplete
		or not autocomplete:IsShown()
		or autocomplete.editBox ~= editBox then
		return false
	end

	local selectedIndex = autocomplete.selectedIndex or 1
	return self:SetAutocompleteSelection(selectedIndex + (key == "UP" and -1 or 1))
end

function GuildPing:InstallChatEditBoxKeyHandlers(editBox)
	if not editBox or not editBox.GetScript or not editBox.SetScript then
		return
	end

	local currentArrowPressed = editBox:GetScript("OnArrowPressed")
	if currentArrowPressed ~= editBox.GuildPingArrowPressedHandler then
		local originalArrowPressed = currentArrowPressed
		local arrowPressedHandler = function(box, key)
			if self:HandleAutocompleteArrow(box, key) then
				return
			end
			if originalArrowPressed then
				return originalArrowPressed(box, key)
			end
		end
		editBox.GuildPingArrowPressedHandler = arrowPressedHandler
		editBox:SetScript("OnArrowPressed", arrowPressedHandler)
	end

	local currentEnterPressed = editBox:GetScript("OnEnterPressed")
	if currentEnterPressed ~= editBox.GuildPingEnterPressedHandler then
		local originalEnterPressed = currentEnterPressed
		local enterPressedHandler = function(box)
			if self:AcceptAutocompleteSelection(box) then
				return
			end
			if originalEnterPressed then
				return originalEnterPressed(box)
			end
		end
		editBox.GuildPingEnterPressedHandler = enterPressedHandler
		editBox:SetScript("OnEnterPressed", enterPressedHandler)
	end

	local currentTabPressed = editBox:GetScript("OnTabPressed")
	if currentTabPressed ~= editBox.GuildPingTabPressedHandler then
		local originalTabPressed = currentTabPressed
		local tabPressedHandler = function(box)
			if self:AcceptAutocompleteSelection(box) then
				return
			end
			if originalTabPressed then
				return originalTabPressed(box)
			end
		end
		editBox.GuildPingTabPressedHandler = tabPressedHandler
		editBox:SetScript("OnTabPressed", tabPressedHandler)
	end
end

function GuildPing:AcceptAutocompleteSelection(editBox, target)
	local autocomplete = self.autocomplete
	if not autocomplete
		or not autocomplete:IsShown()
		or not editBox
		or autocomplete.editBox ~= editBox then
		return false
	end

	target = target or (autocomplete.results and autocomplete.results[autocomplete.selectedIndex or 1])
	local triggerStart = autocomplete.triggerStart
	local triggerEnd = autocomplete.triggerEnd
	if not target or not triggerStart or not triggerEnd then
		return false
	end

	local currentText = editBox:GetText() or ""
	local replacement = "@" .. target.alias .. " "
	local prefix = currentText:sub(1, triggerStart - 1)
	local suffix = currentText:sub(triggerEnd + 1)
	editBox:SetText(prefix .. replacement .. suffix)
	editBox:SetCursorPosition(#prefix + #replacement)
	editBox:SetFocus()
	autocomplete:Hide()
	return true
end

function GuildPing:UpdateAutocomplete(editBox, userInput)
	self:InstallChatEditBoxKeyHandlers(editBox)
	local autocomplete = self:CreateAutocomplete()
	if not userInput or not editBox or not editBox.GetChatType then
		if autocomplete.editBox == editBox then
			autocomplete:Hide()
		end
		return
	end

	local chatType = editBox:GetChatType()
	if chatType ~= "GUILD" and chatType ~= "GUILD_DISCORD" then
		autocomplete:Hide()
		return
	end

	local text = editBox:GetText() or ""
	local cursorPosition = editBox:GetCursorPosition() or #text
	local textBeforeCursor = text:sub(1, cursorPosition)
	local triggerStart, triggerEnd, query = textBeforeCursor:find("@([%w_-]*)$")
	if not triggerStart then
		autocomplete:Hide()
		return
	end

	local characterBeforeTrigger = triggerStart > 1 and textBeforeCursor:sub(triggerStart - 1, triggerStart - 1) or ""
	if characterBeforeTrigger:match("[%w_<]") then
		autocomplete:Hide()
		return
	end

	local results = self:GetSortedTargets(query)
	if #results == 0 then
		autocomplete:Hide()
		return
	end

	autocomplete.editBox = editBox
	autocomplete.triggerStart = triggerStart
	autocomplete.triggerEnd = triggerEnd
	autocomplete.results = results
	autocomplete.selectedIndex = 1
	autocomplete.scrollOffset = 0
	autocomplete:ClearAllPoints()
	autocomplete:SetPoint("BOTTOMLEFT", editBox, "TOPLEFT", 28, 5)
	autocomplete:SetHeight(22 + (math.min(#results, #autocomplete.rows) * 25))

	self:RefreshAutocompleteRows()
	autocomplete:Show()
end

function GuildPing:CreateManager()
	if self.manager then
		return self.manager
	end

	local manager = CreateWindow("GuildPingManagerFrame")
	manager:SetSize(720, 635)
	manager:SetPoint("CENTER")
	manager:SetFrameStrata("DIALOG")
	MakeDraggable(manager)
	SetTitle(manager, "Guild Ping — Manage Mappings")

	manager.profileLabel = CreateLabel(manager, "", "GameFontHighlightSmall")
	manager.profileLabel:SetPoint("TOPLEFT", 18, -42)
	manager.profileLabel:SetTextColor(MUTED_COLOR:GetRGB())

	manager.leftHeader = CreateLabel(manager, "Saved mentions", "GameFontNormalLarge")
	manager.leftHeader:SetPoint("TOPLEFT", 22, -64)
	manager.sortAscendingButton = CreateChoiceButton(manager, "A-Z", 58)
	manager.sortAscendingButton:SetPoint("TOPLEFT", 180, -61)
	manager.sortAscendingButton:SetScript("OnClick", function()
		GuildPing:SetSavedMentionsSort("ASC")
		manager.scrollOffset = 0
		GuildPing:RefreshManager()
	end)
	manager.sortDescendingButton = CreateChoiceButton(manager, "Z-A", 58)
	manager.sortDescendingButton:SetPoint("LEFT", manager.sortAscendingButton, "RIGHT", 6, 0)
	manager.sortDescendingButton:SetScript("OnClick", function()
		GuildPing:SetSavedMentionsSort("DESC")
		manager.scrollOffset = 0
		GuildPing:RefreshManager()
	end)

	manager.rows = {}
	for index = 1, 13 do
		local row = CreateTargetRow(manager, 286)
		row:SetPoint("TOPLEFT", 20, -94 - ((index - 1) * ROW_HEIGHT))
		row:SetScript("OnClick", function(self)
			if self.target then
				GuildPing:SelectManagerTarget(self.target.alias)
			end
		end)
		manager.rows[index] = row
	end

	manager.scrollBar = CreateFrame("Slider", nil, manager, "BackdropTemplate")
	manager.scrollBar:SetPoint("TOPLEFT", 310, -94)
	manager.scrollBar:SetSize(12, 388)
	manager.scrollBar:SetOrientation("VERTICAL")
	manager.scrollBar:SetMinMaxValues(0, 0)
	manager.scrollBar:SetValueStep(1)
	manager.scrollBar:SetObeyStepOnDrag(true)
	manager.scrollBar:SetBackdrop(ROW_BACKDROP)
	manager.scrollBar:SetBackdropColor(0.047, 0.055, 0.078, 0.92)
	manager.scrollBar:SetBackdropBorderColor(0.180, 0.204, 0.275, 0.95)
	manager.scrollBar.thumb = manager.scrollBar:CreateTexture(nil, "OVERLAY")
	manager.scrollBar.thumb:SetColorTexture(DISCORD_BLUE:GetRGBA())
	manager.scrollBar.thumb:SetSize(8, 42)
	manager.scrollBar:SetThumbTexture(manager.scrollBar.thumb)
	manager.scrollBar:SetScript("OnValueChanged", function(_, value)
		if manager.updatingScrollBar then
			return
		end
		local maximum = manager.scrollMaximum or 0
		local nextOffset = math.max(0, math.min(maximum, math.floor(value + 0.5)))
		if nextOffset ~= (manager.scrollOffset or 0) then
			manager.scrollOffset = nextOffset
			GuildPing:RefreshManager()
		end
	end)

	manager.listHint = CreateLabel(manager, "Mouse wheel or drag the scroll bar", "GameFontDisableSmall")
	manager.listHint:SetPoint("TOPLEFT", 22, -490)

	manager.newButton = CreateButton(manager, "New", 80, 25)
	manager.newButton:SetPoint("TOPLEFT", 20, -516)
	manager.newButton:SetScript("OnClick", function()
		GuildPing:ClearManagerEditor()
	end)

	manager.deleteButton = CreateButton(manager, "Delete", 80, 25, "danger")
	manager.deleteButton:SetPoint("LEFT", manager.newButton, "RIGHT", 6, 0)
	manager.deleteButton:SetScript("OnClick", function()
		if manager.selectedAlias and GuildPing:DeleteTarget(manager.selectedAlias) then
			manager.statusText:SetText("Removed @" .. manager.selectedAlias .. ".")
			GuildPing:ClearManagerEditor(true)
			GuildPing:RefreshAllUI()
		end
	end)

	manager.exportButton = CreateButton(manager, "Export", 80, 25)
	manager.exportButton:SetPoint("LEFT", manager.deleteButton, "RIGHT", 6, 0)
	manager.exportButton:SetScript("OnClick", function()
		GuildPing:ShowExportDialog()
	end)

	manager.shareButton = CreateButton(manager, "Share with Guild", 132, 25, "primary")
	manager.shareButton:SetPoint("TOPLEFT", 20, -551)
	manager.shareButton:SetScript("OnClick", function()
		if not GuildPing:CanShareGuildMappings() then
			manager.statusText:SetText("Only the guild leader or an officer can share mappings.")
			return
		end
		local targetCount = #GuildPing:GetSortedTargets()
		if targetCount == 0 then
			manager.statusText:SetText("Add at least one mapping before sharing.")
			return
		end
		EnsureSharePopup()
		StaticPopup_Hide(SHARE_POPUP_KEY)
		StaticPopup_Show(SHARE_POPUP_KEY, targetCount)
	end)
	AddTooltip(
		manager.shareButton,
		"Share with Guild",
		"This can only be used by officers and Guild Masters.",
		"ANCHOR_RIGHT"
	)

	manager.undoButton = CreateButton(manager, "Undo Import", 132, 25)
	manager.undoButton:SetPoint("LEFT", manager.shareButton, "RIGHT", 6, 0)
	manager.undoButton:SetScript("OnClick", function()
		local restoredCount, errorMessage = GuildPing:UndoLastImport()
		if not restoredCount then
			manager.statusText:SetText(errorMessage)
			return
		end
		manager.selectedAlias = nil
		manager.statusText:SetText(string.format("Undid the last import and restored %d mapping(s).", restoredCount))
		GuildPing:RefreshAllUI()
	end)

	local divider = manager:CreateTexture(nil, "ARTWORK")
	divider:SetColorTexture(0.235, 0.267, 0.345, 0.9)
	divider:SetPoint("TOPLEFT", 326, -62)
	divider:SetPoint("BOTTOMLEFT", 326, 18)
	divider:SetWidth(1)

	manager.editorHeader = CreateLabel(manager, "Add or edit a mention", "GameFontNormalLarge")
	manager.editorHeader:SetPoint("TOPLEFT", 348, -64)

	manager.aliasLabel = CreateLabel(manager, "Alias", "GameFontNormal")
	manager.aliasLabel:SetPoint("TOPLEFT", 348, -98)
	manager.aliasInput = CreateInput(manager, 340, "raid, mythic-plus, joe", 24)
	manager.aliasInput:SetPoint("TOPLEFT", 350, -118)

	manager.displayLabel = CreateLabel(manager, "Display name", "GameFontNormal")
	manager.displayLabel:SetPoint("TOPLEFT", 348, -158)
	manager.displayInput = CreateInput(manager, 340, "Raid Team", 48)
	manager.displayInput:SetPoint("TOPLEFT", 350, -178)

	manager.idLabel = CreateLabel(manager, "Discord role or user ID", "GameFontNormal")
	manager.idLabel:SetPoint("TOPLEFT", 348, -218)
	manager.idInput = CreateInput(manager, 340, "Paste an ID or raw <@mention>", 28)
	manager.idInput:SetPoint("TOPLEFT", 350, -238)
	manager.idHelp = CreateLabel(manager, "Discord: enable Developer Mode, then right-click a user or role and choose Copy ID.", "GameFontDisableSmall")
	manager.idHelp:SetPoint("TOPLEFT", 350, -266)
	manager.idHelp:SetPoint("RIGHT", -28, 0)
	manager.idHelp:SetHeight(24)
	manager.idHelp:SetJustifyV("TOP")
	manager.idHelp:SetWordWrap(true)

	manager.typeLabel = CreateLabel(manager, "Mention type", "GameFontNormal")
	manager.typeLabel:SetPoint("TOPLEFT", 348, -288)
	manager.personTypeButton = CreateChoiceButton(manager, "Person Mention", 166)
	manager.personTypeButton:SetPoint("TOPLEFT", 348, -308)
	manager.personTypeButton:SetScript("OnClick", function()
		GuildPing:SetManagerEditorType("USER")
	end)
	manager.roleTypeButton = CreateChoiceButton(manager, "Role Mention", 166)
	manager.roleTypeButton:SetPoint("LEFT", manager.personTypeButton, "RIGHT", 8, 0)
	manager.roleTypeButton:SetScript("OnClick", function()
		GuildPing:SetManagerEditorType("ROLE")
	end)

	manager.saveButton = CreateButton(manager, "Save Mapping", 150, 27, "primary")
	manager.saveButton:SetPoint("TOPLEFT", 348, -350)
	manager.saveButton:SetScript("OnClick", function()
		local target, errorMessage = GuildPing:SaveTarget(
			manager.aliasInput:GetText(),
			manager.displayInput:GetText(),
			manager.editorType,
			manager.idInput:GetText(),
			manager.selectedAlias
		)
		if target then
			manager.selectedAlias = target.alias
			GuildPing:SetManagerEditorType(target.type)
			manager.statusText:SetText(string.format("Saved %s @%s.", target.type == "ROLE" and "role" or "person", target.alias))
			GuildPing:RefreshAllUI()
		else
			manager.statusText:SetText(errorMessage)
		end
	end)
	manager.idInput:SetScript("OnEnterPressed", function(self)
		self:ClearFocus()
		manager.saveButton:Click()
	end)

	manager.statusText = CreateLabel(manager, "", "GameFontHighlightSmall")
	manager.statusText:SetPoint("TOPLEFT", 348, -386)
	manager.statusText:SetPoint("RIGHT", -24, 0)
	manager.statusText:SetWordWrap(true)
	manager.statusText:SetTextColor(DISCORD_BLUE:GetRGB())

	local safetyDivider = manager:CreateTexture(nil, "ARTWORK")
	safetyDivider:SetColorTexture(0.235, 0.267, 0.345, 0.9)
	safetyDivider:SetPoint("TOPLEFT", 348, -416)
	safetyDivider:SetSize(340, 1)

	manager.safetyHeader = CreateLabel(manager, "Chat safety", "GameFontNormalLarge")
	manager.safetyHeader:SetPoint("TOPLEFT", 348, -434)

	manager.massCheck = CreateChoiceButton(manager, "Allow @everyone and @here", 250)
	manager.massCheck:SetPoint("TOPLEFT", 348, -466)
	manager.massCheck:SetScript("OnClick", function()
		GuildPing:SetAllowMassMentions(not GuildPing:GetAllowMassMentions())
		GuildPing:RefreshAllUI()
	end)

	manager.massWarning = CreateLabel(manager, "Off by default. Role and person mappings are unaffected.", "GameFontDisableSmall")
	manager.massWarning:SetPoint("TOPLEFT", 350, -496)

	manager.minimapCheck = CreateChoiceButton(manager, "Show minimap launcher", 250)
	manager.minimapCheck:SetPoint("TOPLEFT", 348, -526)
	manager.minimapCheck:SetScript("OnClick", function()
		GuildPing:SetShowMinimapButton(not GuildPing:GetShowMinimapButton())
		GuildPing:RefreshAllUI()
	end)

	manager.importButton = CreateButton(manager, "Import Mappings", 132, 25, "primary")
	manager.importButton:SetPoint("BOTTOMRIGHT", -18, 10)
	manager.importButton:SetScript("OnClick", function()
		GuildPing:ShowImportDialog()
	end)

	manager.updateNotice = CreateLabel(manager, "", "GameFontNormalSmall")
	manager.updateNotice:SetPoint("BOTTOMLEFT", 22, 42)
	manager.updateNotice:SetPoint("BOTTOMRIGHT", -22, 42)
	manager.updateNotice:SetTextColor(UPDATE_RED:GetRGB())
	manager.updateNotice:SetJustifyH("LEFT")
	manager.updateNotice:Hide()

	manager:SetScript("OnMouseWheel", function(_, delta)
		local total = #GuildPing:GetSortedSavedMentions()
		local maximum = math.max(0, total - #manager.rows)
		manager.scrollOffset = math.max(0, math.min(maximum, (manager.scrollOffset or 0) - delta))
		GuildPing:RefreshManager()
	end)
	manager:EnableMouseWheel(true)
	manager:SetScript("OnShow", function()
		GuildPing:RefreshManager()
	end)

	manager:Hide()
	self.manager = manager
	self:SetManagerEditorType("USER")
	return manager
end

function GuildPing:SetManagerEditorType(targetType)
	local manager = self:CreateManager()
	manager.editorType = targetType == "ROLE" and "ROLE" or "USER"
	SetChoiceButtonSelected(manager.roleTypeButton, manager.editorType == "ROLE")
	SetChoiceButtonSelected(manager.personTypeButton, manager.editorType == "USER")
end

function GuildPing:ClearManagerEditor(preserveStatus)
	local manager = self:CreateManager()
	manager.selectedAlias = nil
	manager.aliasInput:SetText("")
	manager.displayInput:SetText("")
	manager.idInput:SetText("")
	self:SetManagerEditorType("USER")
	if not preserveStatus then
		manager.statusText:SetText("Paste a copied Discord ID or a full raw mention; Guild Ping stores the ID safely as text.")
	end
	self:RefreshManager()
	manager.aliasInput:SetFocus()
end

function GuildPing:SelectManagerTarget(alias)
	local manager = self:CreateManager()
	local target = self:GetTarget(alias)
	if not target then
		return
	end

	manager.selectedAlias = target.alias
	manager.aliasInput:SetText(target.alias)
	manager.displayInput:SetText(target.displayName or target.alias)
	manager.idInput:SetText(target.id)
	self:SetManagerEditorType(target.type)
	manager.statusText:SetText("Editing @" .. target.alias .. ".")
	self:RefreshManager()
end

function GuildPing:RefreshManager()
	if not self.manager then
		return
	end

	local manager = self.manager
	local _, _, profileLabel = self:GetProfile()
	local results = self:GetSortedSavedMentions()
	local maximum = math.max(0, #results - #manager.rows)
	manager.scrollOffset = math.max(0, math.min(manager.scrollOffset or 0, maximum))
	manager.scrollMaximum = maximum
	manager.updatingScrollBar = true
	manager.scrollBar:SetMinMaxValues(0, maximum)
	manager.scrollBar:SetValue(manager.scrollOffset)
	manager.scrollBar:SetAlpha(maximum > 0 and 1 or 0.35)
	manager.scrollBar.thumb:SetShown(maximum > 0)
	manager.updatingScrollBar = false
	manager.profileLabel:SetText("Mappings for " .. profileLabel)
	manager.deleteButton:SetEnabled(manager.selectedAlias ~= nil)
	manager.shareButton:SetEnabled(self:CanShareGuildMappings() and #results > 0)
	manager.undoButton:SetEnabled(self:CanUndoImport())
	SetChoiceButtonSelected(manager.massCheck, self:GetAllowMassMentions())
	SetChoiceButtonSelected(manager.minimapCheck, self:GetShowMinimapButton())
	local sortDirection = self:GetSavedMentionsSort()
	SetChoiceButtonSelected(manager.sortAscendingButton, sortDirection == "ASC")
	SetChoiceButtonSelected(manager.sortDescendingButton, sortDirection == "DESC")

	for rowIndex, row in ipairs(manager.rows) do
		local target = results[manager.scrollOffset + rowIndex]
		PopulateTargetRow(row, target)
		SetTargetRowSelected(row, target and target.alias == manager.selectedAlias)
	end
	self:RefreshVersionNotices()
end

function GuildPing:ShowManager()
	local manager = self:CreateManager()
	manager:Show()
	self:RefreshManager()
	if not manager.selectedAlias then
		self:ClearManagerEditor()
	end
	self:SendVersionAnnouncement()
end

function GuildPing:CreateTransferDialog()
	if self.transferDialog then
		return self.transferDialog
	end

	local dialog = CreateWindow("GuildPingTransferDialog")
	dialog:SetSize(650, 235)
	dialog:SetPoint("CENTER")
	dialog:SetFrameStrata("FULLSCREEN_DIALOG")
	MakeDraggable(dialog)

	dialog.instructions = CreateLabel(dialog, "", "GameFontHighlight")
	dialog.instructions:SetPoint("TOPLEFT", 22, -42)
	dialog.instructions:SetPoint("RIGHT", -22, 0)
	dialog.instructions:SetWordWrap(true)

	dialog.editBackdrop = CreateFrame("Frame", nil, dialog, "BackdropTemplate")
	dialog.editBackdrop:SetPoint("TOPLEFT", 22, -100)
	dialog.editBackdrop:SetPoint("TOPRIGHT", -22, -100)
	dialog.editBackdrop:SetHeight(42)
	dialog.editBackdrop:SetBackdrop(ROW_BACKDROP)
	dialog.editBackdrop:SetBackdropColor(0.047, 0.055, 0.078, 1)
	dialog.editBackdrop:SetBackdropBorderColor(0.235, 0.267, 0.345, 1)

	-- Export payloads are deliberately one line. A plain single-line EditBox is
	-- the same reliable copy pattern used by other installed addons and avoids
	-- the sizing/painting edge cases of a multiline scroll child.
	dialog.editBox = CreateFrame("EditBox", "GuildPingTransferEditBox", dialog.editBackdrop)
	dialog.editBox:SetAllPoints(dialog.editBackdrop)
	dialog.editBox:SetTextInsets(10, 10, 0, 0)
	dialog.editBox:SetFontObject(GameFontHighlight)
	dialog.editBox:SetTextColor(TEXT_COLOR:GetRGB())
	dialog.editBox:SetJustifyH("LEFT")
	dialog.editBox:SetJustifyV("MIDDLE")
	dialog.editBox:EnableMouse(true)
	dialog.editBox:SetAutoFocus(false)
	dialog.editBox:SetMaxLetters(50000)
	dialog.editBox:SetScript("OnEditFocusGained", function(self)
		if dialog.mode == "EXPORT" then
			self:HighlightText(0, self:GetNumLetters())
		end
	end)
	dialog.editBox:SetScript("OnMouseUp", function(self)
		if dialog.mode == "EXPORT" then
			self:HighlightText(0, self:GetNumLetters())
		end
	end)
	dialog.editBox:SetScript("OnEscapePressed", function()
		dialog:Hide()
	end)

	dialog.primaryButton = CreateButton(dialog, "", 120, 25, "primary")
	dialog.primaryButton:SetPoint("BOTTOMRIGHT", -22, 16)

	dialog.selectButton = CreateButton(dialog, "Select All", 100, 25)
	dialog.selectButton:SetPoint("RIGHT", dialog.primaryButton, "LEFT", -6, 0)
	dialog.selectButton:SetScript("OnClick", function()
		dialog.editBox:SetFocus()
		dialog.editBox:HighlightText(0, dialog.editBox:GetNumLetters())
	end)

	dialog.payloadStatus = CreateLabel(dialog, "", "GameFontHighlightSmall")
	dialog.payloadStatus:SetPoint("BOTTOMLEFT", 22, 21)
	dialog.payloadStatus:SetPoint("RIGHT", dialog.selectButton, "LEFT", -10, 0)
	dialog.payloadStatus:SetWordWrap(false)

	dialog:Hide()
	self.transferDialog = dialog
	return dialog
end

function GuildPing:CreateShareInbox()
	if self.shareInbox then
		return self.shareInbox
	end

	local inbox = CreateWindow("GuildPingShareInboxFrame")
	inbox:SetSize(660, 500)
	inbox:SetPoint("CENTER")
	inbox:SetFrameStrata("DIALOG")
	MakeDraggable(inbox)
	SetTitle(inbox, "Guild Ping — Incoming Shares")

	inbox.instructions = CreateLabel(
		inbox,
		"Guild shares wait here until you review or dismiss them. Nothing is applied automatically.",
		"GameFontHighlight"
	)
	inbox.instructions:SetPoint("TOPLEFT", 22, -44)
	inbox.instructions:SetPoint("TOPRIGHT", -22, -44)
	inbox.instructions:SetWordWrap(true)

	inbox.countText = CreateLabel(inbox, "", "GameFontNormal")
	inbox.countText:SetPoint("TOPLEFT", 22, -77)
	inbox.countText:SetTextColor(TEXT_COLOR:GetRGB())

	inbox.rows = {}
	for index = 1, 6 do
		local row = CreateShareInboxRow(inbox, 590)
		row:SetPoint("TOPLEFT", 22, -105 - ((index - 1) * 57))
		inbox.rows[index] = row
	end

	inbox.scrollBar = CreateFrame("Slider", nil, inbox, "BackdropTemplate")
	inbox.scrollBar:SetPoint("TOPLEFT", 624, -105)
	inbox.scrollBar:SetSize(12, 337)
	inbox.scrollBar:SetOrientation("VERTICAL")
	inbox.scrollBar:SetMinMaxValues(0, 0)
	inbox.scrollBar:SetValueStep(1)
	inbox.scrollBar:SetObeyStepOnDrag(true)
	inbox.scrollBar:SetBackdrop(ROW_BACKDROP)
	inbox.scrollBar:SetBackdropColor(0.047, 0.055, 0.078, 0.92)
	inbox.scrollBar:SetBackdropBorderColor(0.180, 0.204, 0.275, 0.95)
	inbox.scrollBar.thumb = inbox.scrollBar:CreateTexture(nil, "OVERLAY")
	inbox.scrollBar.thumb:SetColorTexture(DISCORD_BLUE:GetRGBA())
	inbox.scrollBar.thumb:SetSize(8, 42)
	inbox.scrollBar:SetThumbTexture(inbox.scrollBar.thumb)
	inbox.scrollBar:SetScript("OnValueChanged", function(_, value)
		if inbox.updatingScrollBar then
			return
		end
		local nextOffset = math.max(0, math.min(inbox.scrollMaximum or 0, math.floor(value + 0.5)))
		if nextOffset ~= (inbox.scrollOffset or 0) then
			inbox.scrollOffset = nextOffset
			GuildPing:RefreshShareInbox()
		end
	end)

	inbox.emptyText = CreateLabel(inbox, "No incoming guild shares are waiting.", "GameFontDisable")
	inbox.emptyText:SetPoint("CENTER", 0, 5)

	inbox.clearButton = CreateButton(inbox, "Dismiss All", 110, 25, "danger")
	inbox.clearButton:SetPoint("BOTTOMLEFT", 22, 16)
	inbox.clearButton:SetScript("OnClick", function()
		GuildPing:ClearGuildShareInbox()
	end)

	inbox.closeButton = CreateButton(inbox, "Close", 90, 25)
	inbox.closeButton:SetPoint("BOTTOMRIGHT", -22, 16)
	inbox.closeButton:SetScript("OnClick", function()
		inbox:Hide()
	end)

	inbox:SetScript("OnMouseWheel", function(_, delta)
		inbox.scrollOffset = math.max(
			0,
			math.min(inbox.scrollMaximum or 0, (inbox.scrollOffset or 0) - delta)
		)
		GuildPing:RefreshShareInbox()
	end)
	inbox:EnableMouseWheel(true)
	inbox:SetScript("OnShow", function()
		GuildPing:RefreshShareInbox()
	end)

	inbox:Hide()
	self.shareInbox = inbox
	return inbox
end

function GuildPing:RefreshShareInbox()
	local inbox = self.shareInbox
	if not inbox then
		return
	end

	local entries = self:GetGuildShareInbox()
	local maximum = math.max(0, #entries - #inbox.rows)
	inbox.scrollOffset = math.max(0, math.min(inbox.scrollOffset or 0, maximum))
	inbox.scrollMaximum = maximum
	inbox.updatingScrollBar = true
	inbox.scrollBar:SetMinMaxValues(0, maximum)
	inbox.scrollBar:SetValue(inbox.scrollOffset)
	inbox.scrollBar:SetAlpha(maximum > 0 and 1 or 0.35)
	inbox.scrollBar.thumb:SetShown(maximum > 0)
	inbox.updatingScrollBar = false

	inbox.countText:SetText(string.format(
		"%d incoming share%s",
		#entries,
		#entries == 1 and "" or "s"
	))
	inbox.emptyText:SetShown(#entries == 0)
	inbox.clearButton:SetEnabled(#entries > 0)

	for rowIndex, row in ipairs(inbox.rows) do
		local entryIndex = #entries - (inbox.scrollOffset + rowIndex) + 1
		local entry = entries[entryIndex]
		local preview, errorMessage
		if entry then
			preview, errorMessage = self:BuildGuildShareInboxPreview(entry.id)
		end
		PopulateShareInboxRow(row, entry, preview, errorMessage)
	end
end

function GuildPing:ShowShareInbox()
	local inbox = self:CreateShareInbox()
	inbox.scrollOffset = 0
	inbox:Show()
	self:RefreshShareInbox()
end

function GuildPing:CreateImportPreviewDialog()
	if self.importPreviewDialog then
		return self.importPreviewDialog
	end

	local dialog = CreateWindow("GuildPingImportPreviewFrame")
	dialog:SetSize(700, 505)
	dialog:SetPoint("CENTER")
	dialog:SetFrameStrata("FULLSCREEN_DIALOG")
	MakeDraggable(dialog)
	SetTitle(dialog, "Guild Ping — Review Mapping Changes")

	dialog.sourceLabel = CreateLabel(dialog, "", "GameFontHighlight")
	dialog.sourceLabel:SetPoint("TOPLEFT", 22, -44)
	dialog.sourceLabel:SetPoint("TOPRIGHT", -22, -44)
	dialog.sourceLabel:SetWordWrap(false)

	dialog.summaryText = CreateLabel(dialog, "", "GameFontNormal")
	dialog.summaryText:SetPoint("TOPLEFT", 22, -70)
	dialog.summaryText:SetPoint("TOPRIGHT", -22, -70)
	dialog.summaryText:SetTextColor(TEXT_COLOR:GetRGB())

	dialog.securityText = CreateLabel(dialog, "", "GameFontHighlightSmall")
	dialog.securityText:SetPoint("TOPLEFT", 22, -96)
	dialog.securityText:SetPoint("TOPRIGHT", -22, -96)
	dialog.securityText:SetWordWrap(false)

	dialog.rows = {}
	for index = 1, 10 do
		local row = CreateImportPreviewRow(dialog, 638)
		row:SetPoint("TOPLEFT", 22, -126 - ((index - 1) * 31))
		dialog.rows[index] = row
	end

	dialog.scrollBar = CreateFrame("Slider", nil, dialog, "BackdropTemplate")
	dialog.scrollBar:SetPoint("TOPLEFT", 664, -126)
	dialog.scrollBar:SetSize(12, 307)
	dialog.scrollBar:SetOrientation("VERTICAL")
	dialog.scrollBar:SetMinMaxValues(0, 0)
	dialog.scrollBar:SetValueStep(1)
	dialog.scrollBar:SetObeyStepOnDrag(true)
	dialog.scrollBar:SetBackdrop(ROW_BACKDROP)
	dialog.scrollBar:SetBackdropColor(0.047, 0.055, 0.078, 0.92)
	dialog.scrollBar:SetBackdropBorderColor(0.180, 0.204, 0.275, 0.95)
	dialog.scrollBar.thumb = dialog.scrollBar:CreateTexture(nil, "OVERLAY")
	dialog.scrollBar.thumb:SetColorTexture(DISCORD_BLUE:GetRGBA())
	dialog.scrollBar.thumb:SetSize(8, 42)
	dialog.scrollBar:SetThumbTexture(dialog.scrollBar.thumb)
	dialog.scrollBar:SetScript("OnValueChanged", function(_, value)
		if dialog.updatingScrollBar then
			return
		end
		local nextOffset = math.max(0, math.min(dialog.scrollMaximum or 0, math.floor(value + 0.5)))
		if nextOffset ~= (dialog.scrollOffset or 0) then
			dialog.scrollOffset = nextOffset
			GuildPing:RefreshImportPreview()
		end
	end)

	dialog.cancelButton = CreateButton(dialog, CANCEL, 90, 25)
	dialog.cancelButton:SetPoint("BOTTOMRIGHT", -174, 16)
	dialog.cancelButton:SetScript("OnClick", function()
		dialog:Hide()
	end)

	dialog.applyButton = CreateButton(dialog, "Apply Changes", 145, 25, "primary")
	dialog.applyButton:SetPoint("BOTTOMRIGHT", -22, 16)
	dialog.applyButton:SetScript("OnClick", function()
		local result, errorMessage = GuildPing:ApplyImportPreview(dialog.preview)
		if not result then
			GuildPing:Print(errorMessage)
			return
		end

		local inboxEntryID = dialog.preview.sourceInfo and dialog.preview.sourceInfo.inboxEntryID
		dialog:Hide()
		if inboxEntryID then
			GuildPing:DismissGuildShareInboxEntry(inboxEntryID)
		end
		GuildPing:Print(string.format(
			"Applied mapping changes: %d added, %d updated. Undo is available in Manage.",
			result.added,
			result.updated
		))
		if GuildPing.manager then
			GuildPing.manager.statusText:SetText(string.format(
				"Applied %d added and %d updated mapping(s). Undo is available.",
				result.added,
				result.updated
			))
		end
		GuildPing:RefreshAllUI()
	end)

	dialog:SetScript("OnMouseWheel", function(_, delta)
		dialog.scrollOffset = math.max(
			0,
			math.min(dialog.scrollMaximum or 0, (dialog.scrollOffset or 0) - delta)
		)
		GuildPing:RefreshImportPreview()
	end)
	dialog:EnableMouseWheel(true)
	dialog:SetScript("OnShow", function()
		GuildPing:RefreshImportPreview()
	end)

	dialog:Hide()
	self.importPreviewDialog = dialog
	return dialog
end

function GuildPing:RefreshImportPreview()
	local dialog = self.importPreviewDialog
	local preview = dialog and dialog.preview
	if not dialog or not preview then
		return
	end

	local entries = preview.entries or {}
	local maximum = math.max(0, #entries - #dialog.rows)
	dialog.scrollOffset = math.max(0, math.min(dialog.scrollOffset or 0, maximum))
	dialog.scrollMaximum = maximum
	dialog.updatingScrollBar = true
	dialog.scrollBar:SetMinMaxValues(0, maximum)
	dialog.scrollBar:SetValue(dialog.scrollOffset)
	dialog.scrollBar:SetAlpha(maximum > 0 and 1 or 0.35)
	dialog.scrollBar.thumb:SetShown(maximum > 0)
	dialog.updatingScrollBar = false

	local sourceInfo = preview.sourceInfo or {}
	dialog.sourceLabel:SetText(sourceInfo.label or "Mapping import")
	dialog.summaryText:SetText(string.format(
		"%d added  •  %d updated  •  %d unchanged  •  %d invalid or duplicate skipped",
		preview.added or 0,
		preview.updated or 0,
		preview.unchanged or 0,
		preview.skipped or 0
	))
	if sourceInfo.kind == "GUILD" then
		if sourceInfo.senderVerified then
			dialog.securityText:SetText(string.format(
				"Sender found in your guild roster as %s. Nothing changes until you choose Apply Changes.",
				tostring(sourceInfo.senderRankName or "a guild member")
			))
			dialog.securityText:SetTextColor(GUILD_GREEN:GetRGB())
		else
			dialog.securityText:SetText("The sender was not found in the current guild roster. Review every change carefully before applying.")
			dialog.securityText:SetTextColor(WARNING_GOLD:GetRGB())
		end
	else
		dialog.securityText:SetText("Nothing changes until you choose Apply Changes. Cancel closes this review without modifying mappings.")
		dialog.securityText:SetTextColor(MUTED_COLOR:GetRGB())
	end

	for rowIndex, row in ipairs(dialog.rows) do
		PopulateImportPreviewRow(row, entries[dialog.scrollOffset + rowIndex])
	end
	dialog.applyButton:SetEnabled((preview.added or 0) + (preview.updated or 0) > 0)
end

function GuildPing:ShowImportPreview(preview)
	if type(preview) ~= "table" then
		return
	end
	if self.transferDialog then
		self.transferDialog:Hide()
	end
	local dialog = self:CreateImportPreviewDialog()
	dialog.preview = preview
	dialog.scrollOffset = 0
	dialog:Show()
	self:RefreshImportPreview()
end

function GuildPing:ShowExportDialog()
	local payload = self:ExportProfile()
	local targetCount = #self:GetSortedTargets()
	self.pendingExportPayload = payload
	EnsureExportPopup()
	StaticPopup_Hide(EXPORT_POPUP_KEY)
	local popup = StaticPopup_Show(EXPORT_POPUP_KEY, targetCount, #payload, payload)
	if not popup then
		self:Print(string.format("Export generated (%d characters), but no popup slot was available. Close another dialog and try again.", #payload))
	end
end

function GuildPing:ShowImportDialog()
	local dialog = self:CreateTransferDialog()
	dialog.mode = "IMPORT"
	SetTitle(dialog, "Guild Ping — Import Mappings")
	dialog.instructions:SetText("Paste a Guild Ping export string. You will review every addition and update before this guild's mappings change.")
	dialog.editBox:SetText("")
	dialog.payloadStatus:SetText("Paste the export string into the field above.")
	dialog.payloadStatus:SetTextColor(MUTED_COLOR:GetRGB())
	dialog.primaryButton:SetText("Review Changes")
	dialog.primaryButton:SetVariant("primary")
	dialog.primaryButton:SetScript("OnClick", function()
		local preview, errorMessage = self:BuildImportPreview(
			dialog.editBox:GetText(),
			{ label = "Pasted import" }
		)
		if preview then
			self:ShowImportPreview(preview)
		else
			dialog.payloadStatus:SetText(errorMessage)
			dialog.payloadStatus:SetTextColor(UPDATE_RED:GetRGB())
		end
	end)
	dialog:Show()
	C_Timer.After(0, function()
		if dialog:IsShown() then
			dialog.editBox:SetFocus()
		end
	end)
end

function GuildPing:RefreshVersionNotices()
	local availableVersion = self:GetAvailableUpdateVersion()
	local noticeText = availableVersion
		and string.format("A newer Guild Ping version (v%s) is available.", availableVersion)
		or ""

	if self.picker and self.picker.updateNotice then
		self.picker.updateNotice:SetText(noticeText)
		self.picker.updateNotice:SetShown(availableVersion ~= nil)
		self.picker.tipText:SetShown(availableVersion == nil)
	end

	if self.manager and self.manager.updateNotice then
		self.manager.updateNotice:SetText(noticeText)
		self.manager.updateNotice:SetShown(availableVersion ~= nil)
	end
end

function GuildPing:RefreshShareInboxIndicators()
	local count = self:GetGuildShareInboxCount()
	local countLabel = count > 99 and "99+" or tostring(count)

	if self.picker and self.picker.inboxButton then
		self.picker.inboxButton:SetText(count > 0 and ("Inbox (" .. countLabel .. ")") or "Inbox")
		self.picker.inboxButton:SetEnabled(count > 0)
		self.picker.inboxButton:SetVariant(count > 0 and "primary" or "secondary")
	end

	if self.minimapButton and self.minimapButton.inboxBadge then
		self.minimapButton.inboxBadge.text:SetText(countLabel)
		self.minimapButton.inboxBadge:SetShown(count > 0)
	end
end

function GuildPing:RefreshGameTurnMinimapBadge()
	local count = self.GetGameTurnAlertCount and self:GetGameTurnAlertCount() or 0
	local countLabel = count > 9 and "9+" or tostring(count)
	if self.minimapButton and self.minimapButton.gameTurnBadge then
		self.minimapButton.gameTurnBadge.text:SetText(countLabel)
		self.minimapButton.gameTurnBadge:SetShown(count > 0 and self:GetGameTurnAlertSetting("gameTurnMinimapBadge"))
	end
end

function GuildPing:RefreshAllUI()
	self:RefreshPicker()
	self:RefreshManager()
	self:RefreshVersionNotices()
	self:RefreshShareInbox()
	self:RefreshShareInboxIndicators()
	self:RefreshMinimapButton()
	if self.RefreshCheckersUI then
		self:RefreshCheckersUI()
	end
	if self.RefreshDeathRollUI then
		self:RefreshDeathRollUI()
	end
	if self.RefreshPokerUI then
		self:RefreshPokerUI()
	end
	if self.RefreshConnectFourUI then
		self:RefreshConnectFourUI()
	end
	if self.RefreshFiveDiceUI then
		self:RefreshFiveDiceUI()
	end
	if self.RefreshRuneMastermindUI then
		self:RefreshRuneMastermindUI()
	end
	if self.RefreshGamesIndicators then
		self:RefreshGamesIndicators()
	end
end

function GuildPing:HookChatEditBoxes()
	for _, chatFrameName in ipairs(CHAT_FRAMES or {}) do
		local chatFrame = _G[chatFrameName]
		local editBox = chatFrame and chatFrame.editBox
		if editBox then
			self:InstallChatEditBoxKeyHandlers(editBox)
		end
		if editBox and not editBox.GuildPingAutocompleteHooked then
			editBox.GuildPingAutocompleteHooked = true
			editBox:HookScript("OnTextChanged", function(box, userInput)
				self:UpdateAutocomplete(box, userInput)
			end)
			editBox:HookScript("OnHide", function(box)
				if self.autocomplete and self.autocomplete.editBox == box then
					self.autocomplete:Hide()
				end
			end)
		end
	end
end

local function Atan2(y, x)
	if math.atan2 then
		return math.atan2(y, x)
	end
	if x > 0 then
		return math.atan(y / x)
	elseif x < 0 and y >= 0 then
		return math.atan(y / x) + math.pi
	elseif x < 0 and y < 0 then
		return math.atan(y / x) - math.pi
	elseif y > 0 then
		return math.pi / 2
	elseif y < 0 then
		return -math.pi / 2
	end
	return 0
end

function GuildPing:UpdateMinimapButtonPosition()
	if not self.minimapButton or not Minimap then
		return
	end
	local angle = math.rad(self:GetMinimapAngle())
	self.minimapButton:ClearAllPoints()
	self.minimapButton:SetPoint(
		"CENTER",
		Minimap,
		"CENTER",
		math.cos(angle) * 80,
		math.sin(angle) * 80
	)
end

function GuildPing:RefreshMinimapButton()
	if not self.minimapButton then
		return
	end
	self:UpdateMinimapButtonPosition()
	self.minimapButton:SetShown(self:GetShowMinimapButton())
	self:RefreshShareInboxIndicators()
	self:RefreshGameTurnMinimapBadge()
end

function GuildPing:CreateMinimapButton()
	if self.minimapButton or not Minimap then
		return self.minimapButton
	end

	local button = CreateFrame("Button", "GuildPingMinimapButton", Minimap)
	button:SetSize(32, 32)
	button:SetFrameStrata("MEDIUM")
	button:SetFrameLevel((Minimap:GetFrameLevel() or 0) + 8)
	button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
	button:RegisterForDrag("LeftButton")

	button.background = button:CreateTexture(nil, "BACKGROUND")
	button.background:SetTexture("Interface\\Minimap\\UI-Minimap-Background")
	button.background:SetSize(24, 24)
	button.background:SetPoint("CENTER")

	button.icon = button:CreateTexture(nil, "ARTWORK")
	button.icon:SetTexture(LOGO_TEXTURE)
	button.icon:SetSize(24, 24)
	button.icon:SetPoint("CENTER")

	button.border = button:CreateTexture(nil, "OVERLAY")
	button.border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
	button.border:SetSize(53, 53)
	button.border:SetPoint("TOPLEFT")

	button.highlight = button:CreateTexture(nil, "HIGHLIGHT")
	button.highlight:SetTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
	button.highlight:SetBlendMode("ADD")
	button.highlight:SetSize(32, 32)
	button.highlight:SetPoint("CENTER")

	button.inboxBadge = CreateFrame("Frame", nil, button, "BackdropTemplate")
	button.inboxBadge:SetSize(18, 18)
	button.inboxBadge:SetPoint("TOPRIGHT", 5, 5)
	button.inboxBadge:SetFrameLevel(button:GetFrameLevel() + 10)
	button.inboxBadge:SetBackdrop(ROW_BACKDROP)
	button.inboxBadge:SetBackdropColor(0.78, 0.08, 0.10, 1)
	button.inboxBadge:SetBackdropBorderColor(1, 0.42, 0.42, 1)
	button.inboxBadge.text = button.inboxBadge:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
	button.inboxBadge.text:SetPoint("CENTER", 0, 0)
	button.inboxBadge.text:SetTextColor(1, 1, 1, 1)
	button.inboxBadge:Hide()

	button.gameTurnBadge = CreateFrame("Frame", nil, button, "BackdropTemplate")
	button.gameTurnBadge:SetSize(18, 18)
	button.gameTurnBadge:SetPoint("BOTTOMRIGHT", 5, -5)
	button.gameTurnBadge:SetFrameLevel(button:GetFrameLevel() + 10)
	button.gameTurnBadge:SetBackdrop(ROW_BACKDROP)
	button.gameTurnBadge:SetBackdropColor(0.20, 0.33, 0.86, 1)
	button.gameTurnBadge:SetBackdropBorderColor(0.50, 0.63, 1, 1)
	button.gameTurnBadge.text = button.gameTurnBadge:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
	button.gameTurnBadge.text:SetPoint("CENTER", 0, 0)
	button.gameTurnBadge.text:SetTextColor(1, 1, 1, 1)
	button.gameTurnBadge:Hide()

	button:SetScript("OnClick", function(_, mouseButton)
		if mouseButton == "RightButton" then
			GuildPing:ShowManager()
		else
			GuildPing:ShowPicker()
		end
	end)
	button:SetScript("OnEnter", function(self)
		GameTooltip:SetOwner(self, "ANCHOR_LEFT")
		GameTooltip:AddLine("Guild Ping", 1, 1, 1)
		GameTooltip:AddLine("Left-click: open mention picker", 0.75, 0.78, 0.84)
		GameTooltip:AddLine("Right-click: manage mappings", 0.75, 0.78, 0.84)
		GameTooltip:AddLine("Drag: move around the minimap", 0.75, 0.78, 0.84)
		local inboxCount = GuildPing:GetGuildShareInboxCount()
		if inboxCount > 0 then
			GameTooltip:AddLine(string.format(
				"%d incoming guild share%s waiting",
				inboxCount,
				inboxCount == 1 and "" or "s"
			), 1, 0.35, 0.35)
		end
		local turnCount = GuildPing.GetGameTurnAlertCount and GuildPing:GetGameTurnAlertCount() or 0
		if turnCount > 0 then
			GameTooltip:AddLine(string.format(
				"Your turn in %d Guild Ping game%s",
				turnCount,
				turnCount == 1 and "" or "s"
			), 0.50, 0.63, 1)
		end
		GameTooltip:Show()
	end)
	button:SetScript("OnLeave", function()
		GameTooltip:Hide()
	end)
	button:SetScript("OnDragStart", function(self)
		self:SetScript("OnUpdate", function()
			local scale = UIParent:GetEffectiveScale()
			local cursorX, cursorY = GetCursorPosition()
			local centerX, centerY = Minimap:GetCenter()
			if not cursorX or not cursorY or not centerX or not centerY then
				return
			end
			cursorX, cursorY = cursorX / scale, cursorY / scale
			GuildPing:SetMinimapAngle(math.deg(Atan2(cursorY - centerY, cursorX - centerX)))
			GuildPing:UpdateMinimapButtonPosition()
		end)
	end)
	button:SetScript("OnDragStop", function(self)
		self:SetScript("OnUpdate", nil)
	end)

	self.minimapButton = button
	self:RefreshMinimapButton()
	return button
end

function GuildPing:InitializeUI()
	-- Frames are otherwise created lazily the first time they are opened.
	self:HookChatEditBoxes()
	self:CreateMinimapButton()

	local hookFrame = CreateFrame("Frame")
	hookFrame:RegisterEvent("UPDATE_CHAT_WINDOWS")
	hookFrame:RegisterEvent("UPDATE_FLOATING_CHAT_WINDOWS")
	hookFrame:SetScript("OnEvent", function()
		self:HookChatEditBoxes()
	end)
	self.chatHookFrame = hookFrame
end
