local ADDON_NAME, namespace = ...

local GuildPing = {}
namespace.GuildPing = GuildPing
_G.GuildPing = GuildPing

local PREFIX = "|cff5865f2Guild Ping|r"
local DATABASE_VERSION = 2
local GAME_STATE_VERSION = 1
local GAME_STATE_EXPIRY = 24 * 60 * 60
local GAME_RECONNECT_TIMEOUT = 12
local EXPORT_VERSION = "GP2"
local LEGACY_EXPORT_VERSION = "GP1"
local ADDON_MESSAGE_PREFIX = "GuildPingVer"
local VERSION_MESSAGE_PREFIX = "VERSION:"
local VERSION_ANNOUNCEMENT_COOLDOWN = 30
local SHARE_MESSAGE_PREFIX = "GuildPingMap"
local SHARE_CHUNK_SIZE = 200
local SHARE_MAX_LENGTH = 50000
local SHARE_MAX_CHUNKS = 250
local SHARE_TRANSFER_TIMEOUT = 90
local SHARE_INBOX_LIMIT = 10

local function Trim(value)
	if type(value) ~= "string" then
		return ""
	end
	return (value:gsub("^%s+", ""):gsub("%s+$", ""))
end

local function NormalizeVersion(value)
	local major, minor, patch = Trim(tostring(value or "")):match("^[vV]?(%d+)%.(%d+)%.(%d+)$")
	if not major then
		return nil
	end

	major, minor, patch = tonumber(major), tonumber(minor), tonumber(patch)
	if not major or not minor or not patch
		or major > 999999 or minor > 999999 or patch > 999999 then
		return nil
	end
	return string.format("%d.%d.%d", major, minor, patch)
end

local function IsVersionNewer(candidate, current)
	local normalizedCandidate = NormalizeVersion(candidate)
	local normalizedCurrent = NormalizeVersion(current)
	if not normalizedCandidate or not normalizedCurrent then
		return false
	end

	local candidateMajor, candidateMinor, candidatePatch = normalizedCandidate:match("^(%d+)%.(%d+)%.(%d+)$")
	local currentMajor, currentMinor, currentPatch = normalizedCurrent:match("^(%d+)%.(%d+)%.(%d+)$")
	local candidateParts = { tonumber(candidateMajor), tonumber(candidateMinor), tonumber(candidatePatch) }
	local currentParts = { tonumber(currentMajor), tonumber(currentMinor), tonumber(currentPatch) }

	for index = 1, 3 do
		if candidateParts[index] ~= currentParts[index] then
			return candidateParts[index] > currentParts[index]
		end
	end
	return false
end

local function CopyTargets(targets)
	local copy = {}
	for alias, target in pairs(type(targets) == "table" and targets or {}) do
		if type(target) == "table" then
			copy[alias] = {
				alias = alias,
				displayName = target.displayName,
				type = target.type,
				id = target.id,
			}
		end
	end
	return copy
end

local function TargetsEqual(left, right)
	return type(left) == "table"
		and type(right) == "table"
		and tostring(left.displayName or "") == tostring(right.displayName or "")
		and tostring(left.type or "") == tostring(right.type or "")
		and tostring(left.id or "") == tostring(right.id or "")
end

local function PayloadChecksum(payload)
	local checksum = 0
	for index = 1, #payload do
		checksum = (checksum + (string.byte(payload, index) * index)) % 1000000007
	end
	return checksum
end

local function NormalizePlayerName(name)
	return tostring(name or ""):lower():gsub("%s+", "")
end

local function CopyDefaults(destination, defaults)
	for key, value in pairs(defaults) do
		if destination[key] == nil then
			destination[key] = value
		end
	end
end

local function EscapeField(value)
	return (tostring(value or ""):gsub("([^%w%-%._])", function(character)
		return string.format("%%%02X", string.byte(character))
	end))
end

local function UnescapeField(value)
	return (tostring(value or ""):gsub("%%(%x%x)", function(hex)
		return string.char(tonumber(hex, 16))
	end))
end

local DEFAULT_SETTINGS = {
	allowMassMentions = false,
	expandAliases = true,
	savedMentionsSort = "ASC",
}

local DEFAULT_GLOBAL_SETTINGS = {
	showMinimapButton = true,
	minimapAngle = 225,
	gameTurnSound = true,
	gameTurnScreenAlert = true,
	gameTurnMinimapBadge = true,
	connectFourPieceColor = "DEFAULT",
}

local CONNECT_FOUR_PIECE_COLORS = {
	DEFAULT = true,
	SKY = true,
	TEAL = true,
	VIOLET = true,
	WHITE = true,
}

function GuildPing:Print(message)
	DEFAULT_CHAT_FRAME:AddMessage(string.format("%s: %s", PREFIX, tostring(message)))
end

function GuildPing:GetInstalledVersion()
	if self.installedVersion then
		return self.installedVersion
	end

	local version
	if C_AddOns and C_AddOns.GetAddOnMetadata then
		version = C_AddOns.GetAddOnMetadata(ADDON_NAME, "Version")
	elseif GetAddOnMetadata then
		version = GetAddOnMetadata(ADDON_NAME, "Version")
	end

	self.installedVersion = NormalizeVersion(version) or "0.0.0"
	return self.installedVersion
end

function GuildPing:GetAvailableUpdateVersion()
	local latestVersion = NormalizeVersion(self.latestKnownVersion)
	if latestVersion and IsVersionNewer(latestVersion, self:GetInstalledVersion()) then
		return latestVersion
	end
	return nil
end

function GuildPing:SendVersionAnnouncement()
	if not self.versionCheckInitialized or not IsInGuild or not IsInGuild() then
		return
	end

	local now = GetTime and GetTime() or 0
	if self.lastVersionAnnouncement
		and now - self.lastVersionAnnouncement < VERSION_ANNOUNCEMENT_COOLDOWN then
		return
	end

	self.lastVersionAnnouncement = now
	C_ChatInfo.SendAddonMessage(
		ADDON_MESSAGE_PREFIX,
		VERSION_MESSAGE_PREFIX .. self:GetInstalledVersion(),
		"GUILD"
	)
end

function GuildPing:HandleVersionMessage(prefix, message, channel)
	if not self.versionCheckInitialized
		or prefix ~= ADDON_MESSAGE_PREFIX
		or channel ~= "GUILD"
		or type(message) ~= "string" then
		return
	end

	local remoteVersion = NormalizeVersion(message:match("^" .. VERSION_MESSAGE_PREFIX .. "(.+)$"))
	if not remoteVersion then
		return
	end

	local installedVersion = self:GetInstalledVersion()
	local latestVersion = NormalizeVersion(self.latestKnownVersion) or installedVersion
	if IsVersionNewer(remoteVersion, latestVersion) then
		self.latestKnownVersion = remoteVersion
		if self.RefreshVersionNotices then
			self:RefreshVersionNotices()
		end
	end

	-- A newer client answers an older client's announcement. This lets someone
	-- opening Guild Ping discover an update even if the newer client logged in
	-- much earlier. The normal broadcast cooldown prevents response storms.
	if IsVersionNewer(installedVersion, remoteVersion) then
		self:SendVersionAnnouncement()
	end
end

function GuildPing:InitializeVersionCheck()
	if self.versionCheckInitialized
		or not C_ChatInfo
		or not C_ChatInfo.RegisterAddonMessagePrefix
		or not C_ChatInfo.SendAddonMessage then
		return
	end

	if not C_ChatInfo.RegisterAddonMessagePrefix(ADDON_MESSAGE_PREFIX) then
		return
	end

	self.versionCheckInitialized = true
	self.latestKnownVersion = self:GetInstalledVersion()
	if C_Timer and C_Timer.After then
		C_Timer.After(5, function()
			self:SendVersionAnnouncement()
		end)
	end
end

function GuildPing:EnsureDatabase()
	if type(_G.GuildPingDB) ~= "table" then
		_G.GuildPingDB = {}
	end

	local database = _G.GuildPingDB
	database.version = DATABASE_VERSION
	if type(database.profiles) ~= "table" then
		database.profiles = {}
	end
	if type(database.windowPositions) ~= "table" then
		database.windowPositions = {}
	end
	if type(database.globalSettings) ~= "table" then
		database.globalSettings = {}
	end
	CopyDefaults(database.globalSettings, DEFAULT_GLOBAL_SETTINGS)
	return database
end

function GuildPing:GetGlobalSettings()
	return self:EnsureDatabase().globalSettings
end

function GuildPing:GetShowMinimapButton()
	return self:GetGlobalSettings().showMinimapButton ~= false
end

function GuildPing:SetShowMinimapButton(shown)
	self:GetGlobalSettings().showMinimapButton = not not shown
	if self.RefreshMinimapButton then
		self:RefreshMinimapButton()
	end
end

function GuildPing:GetMinimapAngle()
	local angle = tonumber(self:GetGlobalSettings().minimapAngle) or DEFAULT_GLOBAL_SETTINGS.minimapAngle
	return angle % 360
end

function GuildPing:SetMinimapAngle(angle)
	self:GetGlobalSettings().minimapAngle = (tonumber(angle) or DEFAULT_GLOBAL_SETTINGS.minimapAngle) % 360
end

function GuildPing:GetConnectFourPieceColorSetting()
	local colorKey = self:GetGlobalSettings().connectFourPieceColor
	return CONNECT_FOUR_PIECE_COLORS[colorKey] and colorKey or DEFAULT_GLOBAL_SETTINGS.connectFourPieceColor
end

function GuildPing:SetConnectFourPieceColorSetting(colorKey)
	if not CONNECT_FOUR_PIECE_COLORS[colorKey] then
		return nil, "Choose one of the available Connect Four piece colors."
	end
	self:GetGlobalSettings().connectFourPieceColor = colorKey
	if self.RefreshConnectFourBoard then self:RefreshConnectFourBoard() end
	return true
end

function GuildPing:GetGameTurnAlertSetting(settingKey)
	if settingKey ~= "gameTurnSound" and settingKey ~= "gameTurnScreenAlert"
		and settingKey ~= "gameTurnMinimapBadge" then
		return false
	end
	return self:GetGlobalSettings()[settingKey] ~= false
end

function GuildPing:ToggleGameTurnAlertSetting(settingKey)
	if settingKey ~= "gameTurnSound" and settingKey ~= "gameTurnScreenAlert"
		and settingKey ~= "gameTurnMinimapBadge" then
		return false
	end
	local settings = self:GetGlobalSettings()
	settings[settingKey] = not (settings[settingKey] ~= false)
	if not settings.gameTurnMinimapBadge then
		self.gameTurnAlerts = {}
	end
	if self.RefreshGamesIndicators then
		self:RefreshGamesIndicators()
	end
	return settings[settingKey]
end

function GuildPing:GetGameTurnAlertCount()
	local count = 0
	for _ in pairs(self.gameTurnAlerts or {}) do
		count = count + 1
	end
	return count
end

function GuildPing:AcknowledgeGameTurn(gameKey)
	if self.gameTurnAlerts then
		self.gameTurnAlerts[gameKey] = nil
	end
	if self.RefreshGamesIndicators then
		self:RefreshGamesIndicators()
	end
end

function GuildPing:NotifyGameTurn(gameKey, game, turnToken, message, gameWindowShown)
	if type(game) ~= "table" or game.practice or game.status ~= "ACTIVE"
		or self:IsGameWaitingForResume(game) then
		return false
	end
	local notificationKey = table.concat({ gameKey, tostring(game.id), tostring(turnToken) }, ":")
	self.lastGameTurnNotifications = self.lastGameTurnNotifications or {}
	if self.lastGameTurnNotifications[gameKey] == notificationKey then
		return false
	end
	self.lastGameTurnNotifications[gameKey] = notificationKey

	if not gameWindowShown and self:GetGameTurnAlertSetting("gameTurnSound") and PlaySound then
		local soundID = SOUNDKIT and (SOUNDKIT.READY_CHECK or SOUNDKIT.TELL_MESSAGE) or 8960
		PlaySound(soundID, "Master")
	end
	if not gameWindowShown and self:GetGameTurnAlertSetting("gameTurnScreenAlert")
		and RaidNotice_AddMessage and RaidWarningFrame then
		local color = ChatTypeInfo and ChatTypeInfo.RAID_WARNING or { r = 1, g = 0.82, b = 0 }
		RaidNotice_AddMessage(RaidWarningFrame, message, color)
	end
	if not gameWindowShown and self:GetGameTurnAlertSetting("gameTurnMinimapBadge") then
		self.gameTurnAlerts = self.gameTurnAlerts or {}
		self.gameTurnAlerts[gameKey] = {
			gameID = game.id,
			message = message,
		}
	end
	if self.RefreshGamesIndicators then
		self:RefreshGamesIndicators()
	end
	return true
end

function GuildPing:GetWindowPosition(windowKey)
	local position = self:EnsureDatabase().windowPositions[windowKey]
	if type(position) ~= "table"
		or type(position.point) ~= "string"
		or type(position.relativePoint) ~= "string"
		or type(position.x) ~= "number"
		or type(position.y) ~= "number" then
		return nil
	end
	return position
end

function GuildPing:SaveWindowPosition(windowKey, frame)
	if not windowKey or not frame then
		return
	end

	local point, _, relativePoint, x, y = frame:GetPoint(1)
	if not point or type(x) ~= "number" or type(y) ~= "number" then
		return
	end

	self:EnsureDatabase().windowPositions[windowKey] = {
		point = point,
		relativePoint = relativePoint or point,
		x = x,
		y = y,
	}
end

function GuildPing:GetProfileIdentity()
	local guildName = GetGuildInfo("player")
	local realmName = GetNormalizedRealmName and GetNormalizedRealmName() or GetRealmName() or "UnknownRealm"

	if guildName then
		return string.format("guild:%s:%s", realmName, guildName), guildName
	end

	return "unguilded:" .. realmName, "No guild"
end

function GuildPing:GetProfile()
	local database = self:EnsureDatabase()
	local profileKey, profileLabel = self:GetProfileIdentity()
	local profile = database.profiles[profileKey]

	if type(profile) ~= "table" then
		profile = {}
		database.profiles[profileKey] = profile
	end
	if type(profile.targets) ~= "table" then
		profile.targets = {}
	end
	if type(profile.settings) ~= "table" then
		profile.settings = {}
	end
	CopyDefaults(profile.settings, DEFAULT_SETTINGS)

	profile.profileLabel = profileLabel
	return profile, profileKey, profileLabel
end

local function GetPersistentTimestamp()
	if GetServerTime then
		return tonumber(GetServerTime()) or 0
	end
	if time then
		return tonumber(time()) or 0
	end
	return 0
end

local function IsKnownGameKey(gameKey)
	return gameKey == "checkers" or gameKey == "deathRoll" or gameKey == "poker" or gameKey == "connectFour"
		or gameKey == "fiveDice" or gameKey == "runeMastermind"
end

function GuildPing:GetSavedGameStore()
	local profile = self:GetProfile()
	if type(profile.savedGames) ~= "table" then
		profile.savedGames = {}
	end
	return profile.savedGames
end

function GuildPing:PersistGameState(gameKey, game)
	if not IsKnownGameKey(gameKey) then
		return nil, "That game cannot be saved."
	end
	local savedGames = self:GetSavedGameStore()
	if type(game) ~= "table" then
		savedGames[gameKey] = nil
		return true
	end
	local record = savedGames[gameKey]
	if type(record) ~= "table" then
		record = {}
		savedGames[gameKey] = record
	end
	record.version = GAME_STATE_VERSION
	record.savedAt = GetPersistentTimestamp()
	record.game = game
	return true
end

function GuildPing:ClearSavedGameState(gameKey)
	if not IsKnownGameKey(gameKey) then
		return false
	end
	self:GetSavedGameStore()[gameKey] = nil
	return true
end

function GuildPing:RestoreSavedGameState(gameKey)
	if not IsKnownGameKey(gameKey) then
		return nil
	end
	local savedGames = self:GetSavedGameStore()
	local record = savedGames[gameKey]
	if type(record) ~= "table" or record.version ~= GAME_STATE_VERSION or type(record.game) ~= "table" then
		savedGames[gameKey] = nil
		return nil
	end
	local currentTime = GetPersistentTimestamp()
	local savedAt = tonumber(record.savedAt) or 0
	if currentTime > 0 and savedAt > 0 and currentTime - savedAt > GAME_STATE_EXPIRY then
		savedGames[gameKey] = nil
		return nil, "EXPIRED"
	end
	return record.game
end

function GuildPing:IsGameWaitingForResume(game)
	return game and (game.persistenceState == "RESTORED"
		or game.persistenceState == "RECONNECTING"
		or game.persistenceState == "STALE") or false
end

function GuildPing:BeginGameReconnect(gameKey, game, requestCallback, delay)
	if type(game) ~= "table" or type(requestCallback) ~= "function" then
		return false
	end
	game.persistenceState = "RECONNECTING"
	game.reconnectAttempt = (tonumber(game.reconnectAttempt) or 0) + 1
	self:PersistGameState(gameKey, game)

	local function requestState()
		local record = self:GetSavedGameStore()[gameKey]
		if type(record) == "table" and record.game == game and game.persistenceState == "RECONNECTING" then
			requestCallback()
		end
	end
	if C_Timer and C_Timer.After and (tonumber(delay) or 0) > 0 then
		C_Timer.After(delay, requestState)
	else
		requestState()
	end

	if C_Timer and C_Timer.After then
		C_Timer.After(GAME_RECONNECT_TIMEOUT + (tonumber(delay) or 0), function()
			local record = self:GetSavedGameStore()[gameKey]
			if type(record) == "table" and record.game == game and game.persistenceState == "RECONNECTING" then
				game.persistenceState = "STALE"
				self:PersistGameState(gameKey, game)
				if self.RefreshAllUI then
					self:RefreshAllUI()
				end
			end
		end)
	end
	return true
end

function GuildPing:CompleteGameReconnect(gameKey, game)
	if type(game) ~= "table" then
		return false
	end
	local wasReconnecting = game.persistenceState == "RECONNECTING" or game.persistenceState == "STALE"
	game.persistenceState = nil
	game.reconnectedAt = wasReconnecting and GetPersistentTimestamp() or game.reconnectedAt
	self:PersistGameState(gameKey, game)
	return wasReconnecting
end

function GuildPing:GetGamePersistenceConstants()
	return GAME_STATE_EXPIRY, GAME_RECONNECT_TIMEOUT
end

local function EnsureNumberFields(destination, fields)
	for _, field in ipairs(fields) do
		destination[field] = math.max(0, math.floor(tonumber(destination[field]) or 0))
	end
end

function GuildPing:GetGameStatisticsStore()
	local profile = self:GetProfile()
	if type(profile.gameStatistics) ~= "table" then
		profile.gameStatistics = {}
	end
	local store = profile.gameStatistics
	for _, gameKey in ipairs({ "checkers", "deathRoll", "poker", "connectFour", "fiveDice", "runeMastermind" }) do
		if type(store[gameKey]) ~= "table" then
			store[gameKey] = {}
		end
		EnsureNumberFields(store[gameKey], {
			"played", "wins", "losses", "draws", "resignations", "practiceGames",
			"totalMoves", "longestGameMoves", "totalRolls", "longestGameRolls",
			"highestStartingMax", "handsPlayed", "handsWon", "handsLost", "handsSplit",
			"practiceHands", "soloGames", "soloWins", "soloLosses", "totalScore", "bestScore",
			"totalAttempts", "fewestAttempts",
		})
	end
	if type(store.recentMatches) ~= "table" then
		store.recentMatches = {}
	end
	return store
end

function GuildPing:GetGameStatistics(gameKey)
	return self:GetGameStatisticsStore()[gameKey]
end

function GuildPing:GetRecentGameMatches()
	return self:GetGameStatisticsStore().recentMatches
end

function GuildPing:StorePokerHandHistory(hand)
	if type(hand) ~= "table" or type(hand.gameID) ~= "string" or not tonumber(hand.handNumber) then
		return false
	end
	local store = self:GetGameStatisticsStore()
	if type(store.pokerHands) ~= "table" then
		store.pokerHands = {}
	end
	local historyKey = hand.gameID .. ":" .. tostring(hand.handNumber)
	for _, existing in ipairs(store.pokerHands) do
		if existing.historyKey == historyKey then
			return false
		end
	end
	hand.historyKey = historyKey
	table.insert(store.pokerHands, 1, hand)
	while #store.pokerHands > 30 do
		table.remove(store.pokerHands)
	end
	return true
end

function GuildPing:GetPokerHandHistory()
	local store = self:GetGameStatisticsStore()
	if type(store.pokerHands) ~= "table" then
		store.pokerHands = {}
	end
	return store.pokerHands
end

function GuildPing:RecordGameResult(gameKey, game, result, opponent, detail)
	if not IsKnownGameKey(gameKey) or type(game) ~= "table" or game.statisticsRecorded then
		return false
	end
	game.statisticsRecorded = true
	local store = self:GetGameStatisticsStore()
	local statistics = store[gameKey]
	if game.practice then
		statistics.practiceGames = statistics.practiceGames + 1
	else
		statistics.played = statistics.played + 1
		if result == "WIN" then
			statistics.wins = statistics.wins + 1
		elseif result == "LOSS" then
			statistics.losses = statistics.losses + 1
		else
			statistics.draws = statistics.draws + 1
		end
		if detail and detail.resigned then
			statistics.resignations = statistics.resignations + 1
		end
	end

	if gameKey == "checkers" or gameKey == "connectFour" then
		local moves = math.max(0, math.floor(tonumber(game.moveNumber) or 0))
		statistics.totalMoves = statistics.totalMoves + moves
		statistics.longestGameMoves = math.max(statistics.longestGameMoves, moves)
	elseif gameKey == "deathRoll" then
		local rolls = math.max(0, math.floor(tonumber(game.sequence) or 0))
		statistics.totalRolls = statistics.totalRolls + rolls
		statistics.longestGameRolls = math.max(statistics.longestGameRolls, rolls)
		statistics.highestStartingMax = math.max(
			statistics.highestStartingMax,
			math.max(0, math.floor(tonumber(game.startingMax) or 0))
		)
	end

	local matches = store.recentMatches
	table.insert(matches, 1, {
		game = gameKey,
		gameID = tostring(game.id or ""),
		result = game.practice and "PRACTICE" or result,
		opponent = tostring(opponent or (game.practice and "Local practice" or "Unknown")),
		reason = detail and tostring(detail.reason or "") or "",
		completedAt = GetPersistentTimestamp(),
	})
	while #matches > 30 do
		table.remove(matches)
	end
	return true
end

function GuildPing:RecordSoloGameResult(gameKey, game, won, detail)
	if not IsKnownGameKey(gameKey) or type(game) ~= "table" or game.statisticsRecorded then
		return false
	end
	game.statisticsRecorded = true
	local store = self:GetGameStatisticsStore()
	local statistics = store[gameKey]
	statistics.soloGames = statistics.soloGames + 1
	if won == false then statistics.soloLosses = statistics.soloLosses + 1
	else statistics.soloWins = statistics.soloWins + 1 end

	detail = type(detail) == "table" and detail or {}
	local score = math.max(0, math.floor(tonumber(detail.score) or 0))
	local attempts = math.max(0, math.floor(tonumber(detail.attempts) or 0))
	if score > 0 or gameKey == "fiveDice" then
		statistics.totalScore = statistics.totalScore + score
		statistics.bestScore = math.max(statistics.bestScore, score)
	end
	if attempts > 0 then
		statistics.totalAttempts = statistics.totalAttempts + attempts
		if won ~= false and (statistics.fewestAttempts == 0 or attempts < statistics.fewestAttempts) then
			statistics.fewestAttempts = attempts
		end
	end

	local matches = store.recentMatches
	table.insert(matches, 1, {
		game = gameKey,
		gameID = tostring(game.id or ""),
		result = won == false and "SOLO_LOSS" or "SOLO_WIN",
		opponent = "",
		reason = tostring(detail.reason or ""),
		solo = true,
		completedAt = GetPersistentTimestamp(),
	})
	while #matches > 30 do table.remove(matches) end
	return true
end

function GuildPing:RecordPokerHandResult(game)
	if type(game) ~= "table" or game.handStatus ~= "COMPLETE" or (game.handNumber or 0) < 1
		or not (game.finishReason == "FOLD" or game.finishReason == "SHOWDOWN") then
		return false
	end
	game.statisticsRecordedHands = type(game.statisticsRecordedHands) == "table"
		and game.statisticsRecordedHands or {}
	if game.statisticsRecordedHands[game.handNumber] then
		return false
	end
	game.statisticsRecordedHands[game.handNumber] = true
	local statistics = self:GetGameStatistics("poker")
	if game.practice then
		statistics.practiceHands = statistics.practiceHands + 1
		return true
	end
	statistics.handsPlayed = statistics.handsPlayed + 1
	if game.tie then
		statistics.handsSplit = statistics.handsSplit + 1
	else
		local localSeat = self.GetLocalPokerSeat and self:GetLocalPokerSeat(game) or nil
		if localSeat and game.winnerSeat == localSeat then
			statistics.handsWon = statistics.handsWon + 1
		else
			statistics.handsLost = statistics.handsLost + 1
		end
	end
	return true
end

function GuildPing:NormalizeAlias(alias)
	alias = Trim(alias):lower()
	alias = alias:gsub("^@+", "")
	alias = alias:gsub("%s+", "-")
	alias = alias:gsub("[^%w_-]", "")
	return alias:sub(1, 24)
end

function GuildPing:ValidateTarget(alias, displayName, targetType, discordID)
	alias = self:NormalizeAlias(alias)
	displayName = Trim(displayName)
	targetType = tostring(targetType or ""):upper()
	discordID = Trim(tostring(discordID or ""))

	-- Accept Discord's raw mention syntax as a convenience when someone already
	-- has it handy, while continuing to store only the stable snowflake ID.
	local roleID = discordID:match("^<@&(%d+)>$")
	local userID = discordID:match("^<@!?(%d+)>$")
	if roleID then
		discordID = roleID
		targetType = "ROLE"
	elseif userID then
		discordID = userID
		targetType = "USER"
	end

	if alias == "" then
		return nil, "Enter an alias using letters, numbers, underscores, or hyphens."
	end
	if alias == "everyone" or alias == "here" then
		return nil, "The aliases 'everyone' and 'here' are reserved for safety."
	end
	if targetType ~= "ROLE" and targetType ~= "USER" then
		return nil, "Choose Role or Person."
	end
	if not discordID:match("^%d+$") or #discordID < 15 or #discordID > 22 then
		return nil, "Discord IDs must contain 15 to 22 digits."
	end
	if displayName == "" then
		displayName = alias
	end

	return {
		alias = alias,
		displayName = displayName:sub(1, 48),
		type = targetType,
		id = discordID,
	}
end

function GuildPing:SaveTarget(alias, displayName, targetType, discordID, originalAlias)
	local target, errorMessage = self:ValidateTarget(alias, displayName, targetType, discordID)
	if not target then
		return nil, errorMessage
	end

	local profile = self:GetProfile()
	local oldAlias = self:NormalizeAlias(originalAlias)
	profile.importUndo = nil
	if oldAlias ~= "" and oldAlias ~= target.alias then
		profile.targets[oldAlias] = nil
	end
	profile.targets[target.alias] = target
	return target
end

function GuildPing:DeleteTarget(alias)
	local profile = self:GetProfile()
	alias = self:NormalizeAlias(alias)
	if alias == "" or not profile.targets[alias] then
		return false
	end
	profile.importUndo = nil
	profile.targets[alias] = nil
	return true
end

function GuildPing:GetTarget(alias)
	local profile = self:GetProfile()
	return profile.targets[self:NormalizeAlias(alias)]
end

function GuildPing:GetSortedTargets(query)
	local profile = self:GetProfile()
	local results = {}
	query = Trim(query):lower():gsub("^@", "")

	for alias, target in pairs(profile.targets) do
		if type(target) == "table" and target.id and target.type then
			local displayName = tostring(target.displayName or alias)
			local matches = query == ""
				or alias:lower():find(query, 1, true)
				or displayName:lower():find(query, 1, true)
			if matches then
				target.alias = alias
				table.insert(results, target)
			end
		end
	end

	table.sort(results, function(left, right)
		if left.type ~= right.type then
			return left.type == "ROLE"
		end
		local leftName = tostring(left.displayName or left.alias):lower()
		local rightName = tostring(right.displayName or right.alias):lower()
		if leftName == rightName then
			return left.alias < right.alias
		end
		return leftName < rightName
	end)

	return results
end

function GuildPing:GetSavedMentionsSort()
	local profile = self:GetProfile()
	return profile.settings.savedMentionsSort == "DESC" and "DESC" or "ASC"
end

function GuildPing:SetSavedMentionsSort(direction)
	local profile = self:GetProfile()
	profile.settings.savedMentionsSort = direction == "DESC" and "DESC" or "ASC"
end

function GuildPing:GetSortedSavedMentions()
	local results = self:GetSortedTargets()
	local descending = self:GetSavedMentionsSort() == "DESC"

	table.sort(results, function(left, right)
		local leftName = tostring(left.displayName or left.alias):lower()
		local rightName = tostring(right.displayName or right.alias):lower()
		if leftName == rightName then
			if descending then
				return left.alias > right.alias
			end
			return left.alias < right.alias
		end
		if descending then
			return leftName > rightName
		end
		return leftName < rightName
	end)

	return results
end

function GuildPing:GetMentionToken(target)
	if not target then
		return nil
	end
	if target.type == "ROLE" then
		return "<@&" .. target.id .. ">"
	end
	return "<@" .. target.id .. ">"
end

function GuildPing:FormatMentionTokensForChat(text)
	text = tostring(text or "")
	local roleNames = {}
	local userNames = {}

	-- Use the same deterministic order as the picker if more than one alias was
	-- accidentally saved for a single Discord ID.
	for _, target in ipairs(self:GetSortedTargets()) do
		local discordID = tostring(target.id or "")
		local displayName = Trim(target.displayName)
		if displayName == "" then
			displayName = target.alias
		end
		local names = target.type == "ROLE" and roleNames or userNames
		if discordID ~= "" and not names[discordID] then
			names[discordID] = "@" .. displayName
		end
	end

	text = text:gsub("<@&(%d+)>", function(discordID)
		return roleNames[discordID] or "<@&" .. discordID .. ">"
	end)
	text = text:gsub("<@(!?)(%d+)>", function(bang, discordID)
		return userNames[discordID] or "<@" .. bang .. discordID .. ">"
	end)
	return text
end

function GuildPing:InitializeChatDisplayFilter()
	if self.chatDisplayFilterRegistered or not ChatFrame_AddMessageEventFilter then
		return
	end

	self.chatDisplayFilter = function(_, _, message, ...)
		-- Chat text can become secret during restricted gameplay. Leave it alone
		-- when Blizzard does not permit addons to inspect it.
		if issecretvalue and issecretvalue(message) then
			return false
		end
		if type(message) ~= "string" then
			return false
		end

		local succeeded, formattedMessage = pcall(self.FormatMentionTokensForChat, self, message)
		if not succeeded or formattedMessage == message then
			return false
		end
		return false, formattedMessage, ...
	end

	ChatFrame_AddMessageEventFilter("CHAT_MSG_GUILD", self.chatDisplayFilter)
	ChatFrame_AddMessageEventFilter("CHAT_MSG_GUILD_DISCORD", self.chatDisplayFilter)
	self.chatDisplayFilterRegistered = true
end

local function IsOpaqueKstring(value)
	return type(value) == "string"
		and value:find("|K", 1, true) ~= nil
		and value:find("|k", 1, true) ~= nil
end

function GuildPing:ShowDisplayDiagnostics()
	local function YesNo(value)
		return value and "yes" or "no"
	end

	local addonLoaded = C_AddOns and C_AddOns.IsAddOnLoaded
		and C_AddOns.IsAddOnLoaded("Blizzard_Communities")
	local communitiesFrame = CommunitiesFrame
	local communitiesChat = communitiesFrame and communitiesFrame.Chat
	local messageFrame = communitiesChat and communitiesChat.MessageFrame

	self:Print("Communities display diagnostics (no chat text or IDs are shown):")
	self:Print(string.format(
		"Loaded: %s; live chat: %s; message frame: %s.",
		YesNo(addonLoaded), YesNo(communitiesChat), YesNo(messageFrame)
	))

	local targets = self:GetSortedTargets()
	local sampleTarget = targets[1]
	if sampleTarget then
		local sampleToken = self:GetMentionToken(sampleTarget)
		local succeeded, friendlyToken = pcall(self.FormatMentionTokensForChat, self, sampleToken)
		self:Print("Saved mapping test: " .. (succeeded and friendlyToken ~= sampleToken and "pass." or "fail."))
	else
		self:Print("Saved mapping test: no saved mentions in this guild profile.")
	end

	local selectedStatus = "unavailable"
	local selectedSucceeded, selectedResult = pcall(function()
		local clubId = communitiesFrame and communitiesFrame.GetSelectedClubId
			and communitiesFrame:GetSelectedClubId()
		local clubInfo = clubId and C_Club and C_Club.GetClubInfo and C_Club.GetClubInfo(clubId)
		return clubInfo and Enum and Enum.ClubType
			and clubInfo.clubType == Enum.ClubType.Guild
	end)
	if selectedSucceeded then
		selectedStatus = selectedResult and "yes" or "no"
	end
	self:Print("Selected club is a guild: " .. selectedStatus .. ".")

	local sourceStatus = "unavailable"
	local sourceIsOpaque = false
	local sourceSucceeded, sourceResult, opaqueResult = pcall(function()
		local messages = communitiesChat and communitiesChat.GetMessagesToDisplay
			and communitiesChat:GetMessagesToDisplay()
		local latest = type(messages) == "table" and messages[#messages]
		if type(latest) ~= "table" then
			return "no message", false
		end
		local content = latest.content
		if issecretvalue and issecretvalue(content) then
			return "secret", true
		end
		if type(content) ~= "string" then
			return "not text", false
		end
		if IsOpaqueKstring(content) then
			return "opaque Kstring", true
		end
		local hasSavedMention = self:FormatMentionTokensForChat(content) ~= content
		return hasSavedMention and "readable, saved mention found"
			or "readable, no saved mention found", false
	end)
	if sourceSucceeded and sourceResult then
		sourceStatus = sourceResult
		sourceIsOpaque = opaqueResult
	end
	self:Print("Latest source message: " .. sourceStatus .. ".")

	local renderedStatus = "unavailable"
	local renderedSucceeded, renderedResult = pcall(function()
		if not messageFrame or not messageFrame.GetNumMessages or not messageFrame.GetMessageInfo then
			return nil
		end
		local count = messageFrame:GetNumMessages()
		if not count or count < 1 then
			return "no message"
		end
		local line = messageFrame:GetMessageInfo(count)
		if issecretvalue and issecretvalue(line) then
			return "secret"
		end
		if IsOpaqueKstring(line) then
			return "contains an opaque Kstring"
		end
		return type(line) == "string" and "readable" or "not text"
	end)
	if renderedSucceeded and renderedResult then
		renderedStatus = renderedResult
	end
	self:Print("Latest rendered line: " .. renderedStatus .. ".")

	if sourceIsOpaque then
		self:Print("Result: Blizzard renders this Kstring, but addons cannot inspect or rewrite its hidden message text.")
	end
end

function GuildPing:ExpandAliases(text)
	local profile = self:GetProfile()
	local replacements = 0

	local function ReplaceAlias(rawAlias)
		local target = profile.targets[self:NormalizeAlias(rawAlias)]
		if target then
			replacements = replacements + 1
			return self:GetMentionToken(target)
		end
		return "@" .. rawAlias
	end

	text = tostring(text or "")
	text = text:gsub("^@([%w_-]+)", ReplaceAlias)
	text = text:gsub("([^%w_<])@([%w_-]+)", function(prefix, rawAlias)
		return prefix .. ReplaceAlias(rawAlias)
	end)

	return text, replacements
end

function GuildPing:SanitizeMassMentions(text)
	local profile = self:GetProfile()
	if profile.settings.allowMassMentions then
		return text, 0
	end

	local replacements = 0
	text = text:gsub("@[eE][vV][eE][rR][yY][oO][nN][eE]", function()
		replacements = replacements + 1
		return "[everyone]"
	end)
	text = text:gsub("@[hH][eE][rR][eE]", function()
		replacements = replacements + 1
		return "[here]"
	end)
	return text, replacements
end

function GuildPing:OnEditBoxPreSendText(editBox)
	if not editBox or not editBox.GetChatType or not editBox.GetText then
		return
	end

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

	local profile = self:GetProfile()
	local originalText = editBox:GetText() or ""
	local outgoingText = originalText

	if profile.settings.expandAliases then
		outgoingText = self:ExpandAliases(outgoingText)
	end

	local blockedCount
	outgoingText, blockedCount = self:SanitizeMassMentions(outgoingText)
	if blockedCount > 0 then
		self:Print("Blocked @everyone/@here. You can change this under Manage > Safety.")
	end

	if outgoingText ~= originalText then
		editBox:SetText(outgoingText)
	end
end

function GuildPing:DetermineChatType(activeEditBox)
	if activeEditBox and activeEditBox.GetChatType then
		local activeType = activeEditBox:GetChatType()
		if activeType == "GUILD" or activeType == "GUILD_DISCORD" then
			return activeType
		end
	end

	-- Discord account and guild-link state are exposed through protected APIs.
	-- Addons cannot probe them safely, so Auto falls back to regular guild chat
	-- whenever no Guild or Guild Discord edit box is already active.
	return "GUILD"
end

function GuildPing:GetAllowMassMentions()
	local profile = self:GetProfile()
	return profile.settings.allowMassMentions
end

function GuildPing:SetAllowMassMentions(allowed)
	local profile = self:GetProfile()
	profile.settings.allowMassMentions = not not allowed
end

function GuildPing:ComposeTarget(target, message, preferredEditBox)
	if type(target) == "string" then
		target = self:GetTarget(target)
	end
	if not target then
		self:Print("That mention target no longer exists.")
		return false
	end

	message = Trim(message)
	local fragment = "@" .. target.alias
	if message ~= "" then
		fragment = fragment .. " " .. message
	else
		fragment = fragment .. " "
	end

	local editBox = preferredEditBox
	if not editBox or not editBox.IsShown or not editBox:IsShown() then
		editBox = ChatFrameUtil and ChatFrameUtil.GetActiveWindow and ChatFrameUtil.GetActiveWindow() or nil
	end

	local chatType = self:DetermineChatType(editBox)
	if not editBox or not editBox.IsShown or not editBox:IsShown() then
		if not ChatFrameUtil or not ChatFrameUtil.OpenChat then
			self:Print("The chat frame is not available yet.")
			return false
		end
		editBox = ChatFrameUtil.OpenChat("", DEFAULT_CHAT_FRAME)
	end

	if not editBox then
		self:Print("Could not open a chat box.")
		return false
	end

	editBox:SetChatType(chatType)
	if editBox.UpdateHeader then
		editBox:UpdateHeader()
	end

	local currentText = editBox:GetText() or ""
	if currentText ~= "" then
		local cursorPosition = editBox:GetCursorPosition() or #currentText
		local previousCharacter = cursorPosition > 0 and currentText:sub(cursorPosition, cursorPosition) or ""
		if previousCharacter ~= "" and not previousCharacter:match("%s") then
			fragment = " " .. fragment
		end
		editBox:Insert(fragment)
	else
		editBox:SetText(fragment)
		editBox:SetCursorPosition(#fragment)
	end

	editBox:SetFocus()
	return true
end

function GuildPing:QueueComposeTarget(target, message)
	C_Timer.After(0, function()
		self:ComposeTarget(target, message)
	end)
end

function GuildPing:ExportProfile()
	local parts = { EXPORT_VERSION }
	for _, target in ipairs(self:GetSortedTargets()) do
		local targetCode = target.type == "ROLE" and "R" or "U"
		table.insert(parts, table.concat({
			targetCode,
			EscapeField(target.alias),
			EscapeField(target.displayName or target.alias),
			target.id,
		}, ","))
	end
	return table.concat(parts, ";")
end

function GuildPing:ParseImportPayload(payload)
	payload = Trim(payload):gsub("[\r\n]", "")
	if payload == "" then
		return nil, "Paste a Guild Ping export string first."
	end
	if #payload > SHARE_MAX_LENGTH then
		return nil, string.format("Import strings cannot exceed %d characters.", SHARE_MAX_LENGTH)
	end

	local header, remainder = payload:match("^([^;]+);?(.*)$")
	if header ~= EXPORT_VERSION and header ~= LEGACY_EXPORT_VERSION then
		return nil, "This is not a supported Guild Ping export string."
	end

	local aliases = {}
	local targets = {}
	local skipped = 0
	for record in (remainder .. ";"):gmatch("(.-);") do
		if record ~= "" then
			local targetCode, encodedAlias, encodedDisplay, discordID
			if header == LEGACY_EXPORT_VERSION then
				targetCode, encodedAlias, encodedDisplay, discordID = record:match("^([RU])|([^|]+)|([^|]*)|(%d+)$")
			else
				targetCode, encodedAlias, encodedDisplay, discordID = record:match("^([RU]),([^,]+),([^,]*),(%d+)$")
			end
			if targetCode then
				local target = self:ValidateTarget(
					UnescapeField(encodedAlias),
					UnescapeField(encodedDisplay),
					targetCode == "R" and "ROLE" or "USER",
					discordID
				)
				if target then
					if aliases[target.alias] then
						targets[aliases[target.alias]] = target
						skipped = skipped + 1
					else
						table.insert(targets, target)
						aliases[target.alias] = #targets
					end
				else
					skipped = skipped + 1
				end
			else
				skipped = skipped + 1
			end
		end
	end

	if #targets == 0 then
		return nil, "No valid mappings were found in that string."
	end
	return {
		format = header,
		payload = payload,
		targets = targets,
		skipped = skipped,
	}
end

function GuildPing:BuildImportPreview(payload, sourceInfo)
	local parsed, errorMessage = self:ParseImportPayload(payload)
	if not parsed then
		return nil, errorMessage
	end

	local profile, profileKey = self:GetProfile()
	local preview = {
		profileKey = profileKey,
		payload = parsed.payload,
		sourceInfo = type(sourceInfo) == "table" and sourceInfo or { label = "Pasted import" },
		entries = {},
		added = 0,
		updated = 0,
		unchanged = 0,
		skipped = parsed.skipped,
	}

	for _, target in ipairs(parsed.targets) do
		local current = profile.targets[target.alias]
		local action
		if not current then
			action = "ADD"
			preview.added = preview.added + 1
		elseif TargetsEqual(current, target) then
			action = "UNCHANGED"
			preview.unchanged = preview.unchanged + 1
		else
			action = "UPDATE"
			preview.updated = preview.updated + 1
		end

		table.insert(preview.entries, {
			action = action,
			target = target,
			current = current and {
				alias = target.alias,
				displayName = current.displayName,
				type = current.type,
				id = current.id,
			} or nil,
		})
	end

	local actionOrder = { ADD = 1, UPDATE = 2, UNCHANGED = 3 }
	table.sort(preview.entries, function(left, right)
		if left.action ~= right.action then
			return actionOrder[left.action] < actionOrder[right.action]
		end
		return left.target.alias < right.target.alias
	end)

	return preview
end

function GuildPing:ApplyImportPreview(preview)
	if type(preview) ~= "table" or type(preview.entries) ~= "table" then
		return nil, "That import preview is no longer valid."
	end

	local profile, profileKey = self:GetProfile()
	if preview.profileKey ~= profileKey then
		return nil, "Your active guild changed. Reopen the import and review it again."
	end

	local changes = {}
	for _, entry in ipairs(preview.entries) do
		local target = entry.target
		local current = target and profile.targets[target.alias] or nil
		local currentChanged = (entry.current == nil) ~= (current == nil)
			or (entry.current ~= nil and not TargetsEqual(entry.current, current))
		if currentChanged then
			return nil, "Mappings changed after this preview opened. Review the import again."
		end

		local validated = target and self:ValidateTarget(
			target.alias,
			target.displayName,
			target.type,
			target.id
		)
		if validated and entry.action ~= "UNCHANGED" then
			table.insert(changes, validated)
		end
	end

	if #changes == 0 then
		return nil, "There are no mapping changes to apply."
	end

	profile.importUndo = {
		targets = CopyTargets(profile.targets),
		createdAt = GetServerTime and GetServerTime() or 0,
		source = preview.sourceInfo and preview.sourceInfo.label or "Import",
	}

	local added = 0
	local updated = 0
	for _, target in ipairs(changes) do
		if profile.targets[target.alias] then
			updated = updated + 1
		else
			added = added + 1
		end
		profile.targets[target.alias] = target
	end

	return {
		added = added,
		updated = updated,
		unchanged = #preview.entries - #changes,
		skipped = preview.skipped or 0,
	}
end

function GuildPing:CanUndoImport()
	local profile = self:GetProfile()
	return type(profile.importUndo) == "table" and type(profile.importUndo.targets) == "table"
end

function GuildPing:UndoLastImport()
	local profile = self:GetProfile()
	if type(profile.importUndo) ~= "table" or type(profile.importUndo.targets) ~= "table" then
		return nil, "There is no import to undo for this guild."
	end

	local restoredCount = 0
	for _ in pairs(profile.importUndo.targets) do
		restoredCount = restoredCount + 1
	end
	profile.targets = CopyTargets(profile.importUndo.targets)
	profile.importUndo = nil
	return restoredCount
end

function GuildPing:ImportProfile(payload)
	local preview, errorMessage = self:BuildImportPreview(payload)
	if not preview then
		return nil, errorMessage
	end
	local result, applyError = self:ApplyImportPreview(preview)
	if not result then
		return nil, applyError
	end
	return result.added + result.updated, result.skipped
end

function GuildPing:CanShareGuildMappings()
	if not IsInGuild or not IsInGuild() then
		return false
	end
	if CanEditOfficerNote then
		return CanEditOfficerNote()
	end
	if not GetGuildInfo then
		return false
	end
	local _, _, rankIndex = GetGuildInfo("player")
	return rankIndex == 0 or rankIndex == 1
end

function GuildPing:GetGuildShareSenderRank(sender)
	if not GetNumGuildMembers or not GetGuildRosterInfo then
		return nil
	end

	local normalizedSender = NormalizePlayerName(sender)
	local senderBase = normalizedSender:match("^([^-]+)")
	local senderHasRealm = normalizedSender:find("-", 1, true) ~= nil
	local memberCount = tonumber(GetNumGuildMembers()) or 0
	for index = 1, memberCount do
		local memberName, rankName, rankIndex = GetGuildRosterInfo(index)
		local normalizedMember = NormalizePlayerName(memberName)
		local memberBase = normalizedMember:match("^([^-]+)")
		local memberHasRealm = normalizedMember:find("-", 1, true) ~= nil
		if normalizedMember == normalizedSender
			or ((not senderHasRealm or not memberHasRealm) and senderBase ~= "" and senderBase == memberBase) then
			return rankIndex, rankName
		end
	end
	return nil
end

function GuildPing:IsSelfAddonSender(sender)
	local playerName
	if GetUnitName then
		playerName = GetUnitName("player", true)
	elseif UnitName then
		playerName = UnitName("player")
	end
	local normalizedPlayer = NormalizePlayerName(playerName)
	local normalizedSender = NormalizePlayerName(sender)
	if normalizedPlayer == normalizedSender then
		return true
	end
	local playerHasRealm = normalizedPlayer:find("-", 1, true) ~= nil
	local senderHasRealm = normalizedSender:find("-", 1, true) ~= nil
	return (not playerHasRealm or not senderHasRealm)
		and normalizedPlayer:match("^([^-]+)") == normalizedSender:match("^([^-]+)")
end

function GuildPing:SendGuildMappings()
	if not self.guildSharingInitialized then
		return nil, "Guild mapping sharing is not available."
	end
	if not self:CanShareGuildMappings() then
		return nil, "Only the guild leader or an officer can share mappings."
	end

	local payload = self:ExportProfile()
	if #self:GetSortedTargets() == 0 then
		return nil, "Add at least one mapping before sharing."
	end
	if #payload > SHARE_MAX_LENGTH then
		return nil, string.format("The mapping export is too large to share (%d character limit).", SHARE_MAX_LENGTH)
	end

	local now = GetTime and GetTime() or 0
	if self.lastGuildShareTime and now - self.lastGuildShareTime < 10 then
		return nil, "Wait a few seconds before sharing mappings again."
	end

	local totalChunks = math.ceil(#payload / SHARE_CHUNK_SIZE)
	if totalChunks < 1 or totalChunks > SHARE_MAX_CHUNKS then
		return nil, "The mapping export requires too many transfer chunks."
	end

	local serverTime = GetServerTime and GetServerTime() or math.floor(now)
	local transferID = string.format("%x%x", serverTime, math.random(0, 0xFFFF))
	local startMessage = string.format(
		"S|%s|%d|%d|%d",
		transferID,
		totalChunks,
		#payload,
		PayloadChecksum(payload)
	)
	local started, sendResult = pcall(C_ChatInfo.SendAddonMessage, SHARE_MESSAGE_PREFIX, startMessage, "GUILD")
	if not started or sendResult == false then
		return nil, "WoW could not start the guild mapping transfer."
	end

	self.lastGuildShareTime = now
	for index = 1, totalChunks do
		local firstCharacter = ((index - 1) * SHARE_CHUNK_SIZE) + 1
		local chunk = payload:sub(firstCharacter, firstCharacter + SHARE_CHUNK_SIZE - 1)
		local chunkMessage = string.format("C|%s|%d|%s", transferID, index, chunk)
		C_Timer.After(index * 0.08, function()
			pcall(C_ChatInfo.SendAddonMessage, SHARE_MESSAGE_PREFIX, chunkMessage, "GUILD")
		end)
	end

	return {
		mappingCount = #self:GetSortedTargets(),
		payloadLength = #payload,
		chunkCount = totalChunks,
	}
end

function GuildPing:CleanupGuildShareTransfers()
	local now = GetTime and GetTime() or 0
	for key, transfer in pairs(self.incomingGuildShares or {}) do
		if now - (transfer.startedAt or 0) > SHARE_TRANSFER_TIMEOUT then
			self.incomingGuildShares[key] = nil
		end
	end
	for key, completedAt in pairs(self.completedGuildShares or {}) do
		if now - completedAt > 300 then
			self.completedGuildShares[key] = nil
		end
	end
end

function GuildPing:GetGuildShareInbox()
	if type(self.guildShareInbox) ~= "table" then
		self.guildShareInbox = {}
	end
	return self.guildShareInbox
end

function GuildPing:GetGuildShareInboxCount()
	return #self:GetGuildShareInbox()
end

function GuildPing:GetGuildShareInboxEntry(entryID)
	for index, entry in ipairs(self:GetGuildShareInbox()) do
		if entry.id == entryID then
			return entry, index
		end
	end
	return nil
end

function GuildPing:RefreshGuildShareInboxUI()
	if self.RefreshShareInbox then
		self:RefreshShareInbox()
	end
	if self.RefreshShareInboxIndicators then
		self:RefreshShareInboxIndicators()
	end
end

function GuildPing:QueueGuildShare(payload, transfer, preview)
	local inbox = self:GetGuildShareInbox()
	for _, entry in ipairs(inbox) do
		if transfer.transferKey and entry.transferKey == transfer.transferKey then
			return entry
		end
	end

	self.guildShareInboxNextID = (self.guildShareInboxNextID or 0) + 1
	local entry = {
		id = self.guildShareInboxNextID,
		transferKey = transfer.transferKey,
		profileKey = preview.profileKey,
		payload = payload,
		sender = transfer.sender,
		senderRank = transfer.senderRank,
		senderRankName = transfer.senderRankName,
		senderVerified = transfer.senderRank ~= nil,
		receivedAt = GetServerTime and GetServerTime() or 0,
		added = preview.added or 0,
		updated = preview.updated or 0,
		unchanged = preview.unchanged or 0,
		skipped = preview.skipped or 0,
	}
	table.insert(inbox, entry)

	local discardedOldest = false
	if #inbox > SHARE_INBOX_LIMIT then
		table.remove(inbox, 1)
		discardedOldest = true
	end

	if InCombatLockdown and InCombatLockdown() then
		entry.notificationDeferred = true
		if discardedOldest then
			self.guildShareInboxDiscardedDuringCombat = true
		end
	else
		self:Print(transfer.sender .. " shared Guild Ping mappings. Open /gp and choose Inbox to review them.")
		if discardedOldest then
			self:Print("The incoming-share inbox is full, so its oldest entry was discarded.")
		end
	end
	self:RefreshGuildShareInboxUI()
	return entry
end

function GuildPing:NotifyDeferredGuildShares()
	local deferredCount = 0
	for _, entry in ipairs(self:GetGuildShareInbox()) do
		if entry.notificationDeferred then
			deferredCount = deferredCount + 1
		end
	end
	if deferredCount < 1 and not self.guildShareInboxDiscardedDuringCombat then
		return
	end
	if InCombatLockdown and InCombatLockdown() then
		return
	end

	for _, entry in ipairs(self:GetGuildShareInbox()) do
		entry.notificationDeferred = nil
	end
	if deferredCount > 0 then
		self:Print(string.format(
			"%d Guild Ping mapping share%s arrived during combat. Open /gp and choose Inbox to review %s.",
			deferredCount,
			deferredCount == 1 and "" or "s",
			deferredCount == 1 and "it" or "them"
		))
	end
	if self.guildShareInboxDiscardedDuringCombat then
		self:Print("The incoming-share inbox filled during combat, so its oldest entry was discarded.")
		self.guildShareInboxDiscardedDuringCombat = nil
	end
end

function GuildPing:BuildGuildShareInboxPreview(entryID)
	local entry = self:GetGuildShareInboxEntry(entryID)
	if not entry then
		return nil, "That incoming share is no longer in the inbox."
	end
	local _, profileKey = self:GetProfile()
	if entry.profileKey ~= profileKey then
		return nil, "That share was received for a different guild and cannot be applied here."
	end

	return self:BuildImportPreview(entry.payload, {
		label = "Guild share from " .. entry.sender,
		kind = "GUILD",
		sender = entry.sender,
		senderVerified = entry.senderVerified,
		senderRank = entry.senderRank,
		senderRankName = entry.senderRankName,
		inboxEntryID = entry.id,
	})
end

function GuildPing:DismissGuildShareInboxEntry(entryID)
	local _, index = self:GetGuildShareInboxEntry(entryID)
	if not index then
		return false
	end
	table.remove(self.guildShareInbox, index)
	self:RefreshGuildShareInboxUI()
	return true
end

function GuildPing:ClearGuildShareInbox()
	local inbox = self:GetGuildShareInbox()
	for index = #inbox, 1, -1 do
		table.remove(inbox, index)
	end
	self.guildShareInboxDiscardedDuringCombat = nil
	self:RefreshGuildShareInboxUI()
end

function GuildPing:CompleteGuildShareTransfer(key, transfer)
	local parts = {}
	for index = 1, transfer.totalChunks do
		if type(transfer.chunks[index]) ~= "string" then
			return
		end
		parts[index] = transfer.chunks[index]
	end

	local payload = table.concat(parts)
	self.incomingGuildShares[key] = nil
	self.completedGuildShares[key] = GetTime and GetTime() or 0
	if #payload ~= transfer.payloadLength or PayloadChecksum(payload) ~= transfer.checksum then
		self:Print("Ignored an incomplete or damaged mapping share from " .. transfer.sender .. ".")
		return
	end

	local verified = transfer.senderRank ~= nil
	local preview, errorMessage = self:BuildImportPreview(payload, {
		label = "Guild share from " .. transfer.sender,
		kind = "GUILD",
		sender = transfer.sender,
		senderVerified = verified,
		senderRank = transfer.senderRank,
		senderRankName = transfer.senderRankName,
	})
	if not preview then
		self:Print("Ignored a mapping share from " .. transfer.sender .. ": " .. errorMessage)
		return
	end

	transfer.transferKey = key
	self:QueueGuildShare(payload, transfer, preview)
end

function GuildPing:HandleGuildShareMessage(prefix, message, channel, sender)
	if not self.guildSharingInitialized
		or prefix ~= SHARE_MESSAGE_PREFIX
		or channel ~= "GUILD"
		or type(message) ~= "string"
		or type(sender) ~= "string"
		or self:IsSelfAddonSender(sender) then
		return
	end

	self:CleanupGuildShareTransfers()
	local messageType = message:sub(1, 1)
	if messageType == "S" then
		local transferID, totalChunks, payloadLength, checksum = message:match("^S|([%w%-]+)|(%d+)|(%d+)|(%d+)$")
		totalChunks = tonumber(totalChunks)
		payloadLength = tonumber(payloadLength)
		checksum = tonumber(checksum)
		if not transferID or not totalChunks or not payloadLength or not checksum
			or totalChunks < 1 or totalChunks > SHARE_MAX_CHUNKS
			or payloadLength < 1 or payloadLength > SHARE_MAX_LENGTH then
			return
		end

		local senderRank, senderRankName = self:GetGuildShareSenderRank(sender)

		local key = NormalizePlayerName(sender) .. ":" .. transferID
		if self.completedGuildShares[key] then
			return
		end
		local activeTransfers = 0
		for _ in pairs(self.incomingGuildShares) do
			activeTransfers = activeTransfers + 1
		end
		if activeTransfers >= 5 and not self.incomingGuildShares[key] then
			return
		end

		self.incomingGuildShares[key] = {
			sender = sender,
			senderRank = senderRank,
			senderRankName = senderRankName,
			totalChunks = totalChunks,
			payloadLength = payloadLength,
			checksum = checksum,
			chunks = {},
			receivedChunks = 0,
			startedAt = GetTime and GetTime() or 0,
		}
	elseif messageType == "C" then
		local transferID, chunkIndex, chunk = message:match("^C|([%w%-]+)|(%d+)|(.*)$")
		chunkIndex = tonumber(chunkIndex)
		if not transferID or not chunkIndex or #chunk > SHARE_CHUNK_SIZE then
			return
		end

		local key = NormalizePlayerName(sender) .. ":" .. transferID
		local transfer = self.incomingGuildShares[key]
		if not transfer or chunkIndex < 1 or chunkIndex > transfer.totalChunks then
			return
		end
		if transfer.chunks[chunkIndex] == nil then
			transfer.chunks[chunkIndex] = chunk
			transfer.receivedChunks = transfer.receivedChunks + 1
		end
		if transfer.receivedChunks == transfer.totalChunks then
			self:CompleteGuildShareTransfer(key, transfer)
		end
	end
end

function GuildPing:InitializeGuildSharing()
	if self.guildSharingInitialized
		or not C_ChatInfo
		or not C_ChatInfo.RegisterAddonMessagePrefix
		or not C_ChatInfo.SendAddonMessage then
		return
	end
	if not C_ChatInfo.RegisterAddonMessagePrefix(SHARE_MESSAGE_PREFIX) then
		return
	end

	self.guildSharingInitialized = true
	self.incomingGuildShares = {}
	self.completedGuildShares = {}
	self.guildShareInbox = self.guildShareInbox or {}
	if C_GuildInfo and C_GuildInfo.GuildRoster then
		C_GuildInfo.GuildRoster()
	end
end

function GuildPing:ShowHelp()
	self:Print("/gp - open the searchable mention picker")
	self:Print("/gp <alias> [message] - compose a guild message")
	self:Print("/gp manage - edit mappings and safety settings")
	self:Print("/gp games - open the Guild Ping games hub")
	self:Print("/gp checkers | /gp deathroll | /gp poker | /gp connectfour - open a multiplayer game")
	self:Print("/gp fivedice | /gp mastermind - open a solo game")
	self:Print("/gp debug - diagnose friendly mention display")
	self:Print("/gp add role|user <alias> <Discord ID> [display name]")
	self:Print("/gp remove <alias> | /gp export | /gp import <string>")
	self:Print("Manage also provides reviewed imports, one-level undo, officer guild sharing, and minimap settings.")
	self:Print("Discord IDs: enable Developer Mode, then right-click a user or role and choose Copy ID.")
	self:Print("You can also type a configured @alias directly in guild chat.")
end

function GuildPing:HandleSlashCommand(input)
	input = Trim(input)
	if input == "" then
		self:ShowPicker()
		return
	end

	local command, remainder = input:match("^(%S+)%s*(.-)$")
	command = tostring(command or ""):lower()
	remainder = Trim(remainder)

	if command == "help" or command == "?" then
		self:ShowHelp()
	elseif command == "debug" or command == "diagnose" then
		self:ShowDisplayDiagnostics()
	elseif command == "manage" or command == "config" or command == "options" then
		self:ShowManager()
	elseif command == "game" or command == "games" then
		if self.ShowGames then
			self:ShowGames()
		elseif self.ShowCheckers then
			self:ShowCheckers()
		else
			self:Print("Guild Ping games are not available yet.")
		end
	elseif command == "checkers" then
		if self.ShowCheckers then
			self:ShowCheckers()
		end
	elseif command == "deathroll" or command == "death-roll" or command == "dr" then
		if self.ShowDeathRoll then
			self:ShowDeathRoll()
		end
	elseif command == "poker" or command == "holdem" or command == "hold-em" or command == "texas" then
		if self.ShowPoker then
			self:ShowPoker()
		end
	elseif command == "connectfour" or command == "connect4" or command == "c4" then
		if self.ShowConnectFour then
			self:ShowConnectFour()
		end
	elseif command == "fivedice" or command == "five-dice" or command == "dice" then
		if self.ShowFiveDice then
			self:ShowFiveDice()
		end
	elseif command == "mastermind" or command == "runemastermind" or command == "runes" then
		if self.ShowRuneMastermind then
			self:ShowRuneMastermind()
		end
	elseif command == "export" then
		self:ShowExportDialog()
	elseif command == "import" then
		if remainder == "" then
			self:ShowImportDialog()
		else
			local preview, errorMessage = self:BuildImportPreview(remainder, { label = "Slash command import" })
			if preview then
				self:ShowImportPreview(preview)
			else
				self:Print(errorMessage)
			end
		end
	elseif command == "remove" or command == "delete" then
		if self:DeleteTarget(remainder) then
			self:Print("Removed @" .. self:NormalizeAlias(remainder) .. ".")
			self:RefreshAllUI()
		else
			self:Print("No mapping named @" .. self:NormalizeAlias(remainder) .. " was found.")
		end
	elseif command == "add" then
		local targetType, alias, discordID, displayName = remainder:match("^(%S+)%s+(%S+)%s+(%d+)%s*(.-)$")
		if not targetType then
			self:Print("Usage: /gp add role|user <alias> <Discord ID> [display name]")
			return
		end
		targetType = targetType:lower() == "role" and "ROLE" or targetType:lower() == "user" and "USER" or ""
		local target, errorMessage = self:SaveTarget(alias, displayName, targetType, discordID)
		if target then
			self:Print(string.format("Saved %s @%s (%s).", target.type == "ROLE" and "role" or "person", target.alias, target.displayName))
			self:RefreshAllUI()
		else
			self:Print(errorMessage)
		end
	elseif command == "mass" then
		if remainder == "on" then
			self:SetAllowMassMentions(true)
			self:Print("@everyone and @here are now allowed.")
		elseif remainder == "off" then
			self:SetAllowMassMentions(false)
			self:Print("@everyone and @here are now blocked.")
		else
			self:Print("Usage: /gp mass on|off")
		end
	else
		local target = self:GetTarget(command)
		if target then
			self:QueueComposeTarget(target, remainder)
		else
			self:ShowPicker(input)
		end
	end
end

function GuildPing:Initialize()
	self:EnsureDatabase()

	SLASH_GUILDPING1 = "/gping"
	SLASH_GUILDPING2 = "/gp"
	SlashCmdList.GUILDPING = function(input)
		self:HandleSlashCommand(input)
	end

	if EventRegistry and EventRegistry.RegisterCallback then
		EventRegistry:RegisterCallback("ChatFrame.OnEditBoxPreSendText", function(_, editBox)
			self:OnEditBoxPreSendText(editBox)
		end, self)
	end

	self:InitializeChatDisplayFilter()

	if self.InitializeUI then
		self:InitializeUI()
	end
	self:InitializeVersionCheck()
	self:InitializeGuildSharing()
	if self.InitializeCheckers then
		self:InitializeCheckers()
	end
	if self.InitializeDeathRoll then
		self:InitializeDeathRoll()
	end
	if self.InitializePoker then
		self:InitializePoker()
	end
	if self.InitializeConnectFour then
		self:InitializeConnectFour()
	end
	if self.InitializeFiveDice then
		self:InitializeFiveDice()
	end
	if self.InitializeRuneMastermind then
		self:InitializeRuneMastermind()
	end
	if self.InitializeGameSocial then
		self:InitializeGameSocial()
	end
	if self.InitializeSpectatorMode then
		self:InitializeSpectatorMode()
	end
	self.initialized = true
end

local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterEvent("CHAT_MSG_ADDON")
eventFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
eventFrame:SetScript("OnEvent", function(_, event, ...)
	if event == "PLAYER_LOGIN" then
		GuildPing:Initialize()
	elseif event == "CHAT_MSG_ADDON" then
		GuildPing:HandleVersionMessage(...)
		GuildPing:HandleGuildShareMessage(...)
		if GuildPing.HandleCheckersMessage then
			GuildPing:HandleCheckersMessage(...)
		end
		if GuildPing.HandleDeathRollMessage then
			GuildPing:HandleDeathRollMessage(...)
		end
		if GuildPing.HandlePokerMessage then
			GuildPing:HandlePokerMessage(...)
		end
		if GuildPing.HandleConnectFourMessage then
			GuildPing:HandleConnectFourMessage(...)
		end
		if GuildPing.HandleGameSocialMessage then
			GuildPing:HandleGameSocialMessage(...)
		end
		if GuildPing.HandleSpectatorMessage then
			GuildPing:HandleSpectatorMessage(...)
		end
	elseif event == "PLAYER_REGEN_ENABLED" then
		GuildPing:NotifyDeferredGuildShares()
	end
end)
