local _, namespace = ...
local GuildPing = namespace.GuildPing

local MESSAGE_PREFIX = "GuildPingDeath"
local PROTOCOL_VERSION = "1"
local CHALLENGE_TIMEOUT = 300
local INVITE_COOLDOWN = 15
local MAX_INVITES = 3
local MIN_STARTING_MAX = 2
local MAX_STARTING_MAX = 1000000
local CHALLENGER = "CHALLENGER"
local OPPONENT = "OPPONENT"

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

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

local function PlayerBaseName(name)
	return NormalizePlayerName(name):match("^([^-]+)") or ""
end

local function NamesMatch(left, right)
	local normalizedLeft = NormalizePlayerName(left)
	local normalizedRight = NormalizePlayerName(right)
	if normalizedLeft == "" or normalizedRight == "" then
		return false
	end
	if normalizedLeft == normalizedRight then
		return true
	end
	local leftHasRealm = normalizedLeft:find("-", 1, true) ~= nil
	local rightHasRealm = normalizedRight:find("-", 1, true) ~= nil
	return (not leftHasRealm or not rightHasRealm)
		and PlayerBaseName(normalizedLeft) == PlayerBaseName(normalizedRight)
end

local function GetLocalPlayerName()
	if GetUnitName then
		local name = GetUnitName("player", true)
		if name and name ~= "" then
			return name
		end
	end
	if UnitFullName then
		local name, realm = UnitFullName("player")
		if name and realm and realm ~= "" then
			return name .. "-" .. realm
		end
		if name then
			return name
		end
	end
	return UnitName and UnitName("player") or "player"
end

local function OtherSeat(seat)
	return seat == CHALLENGER and OPPONENT or CHALLENGER
end

local function NormalizeStartingMax(value)
	value = math.floor(tonumber(value) or 1000)
	return math.max(MIN_STARTING_MAX, math.min(MAX_STARTING_MAX, value))
end

local function HashString(value)
	local hash = 17
	for index = 1, #value do
		hash = ((hash * 131) + string.byte(value, index)) % 2147483647
	end
	return hash
end

local function CreateGameID()
	local serverTime = GetServerTime and GetServerTime() or math.floor(GetTime and GetTime() or 0)
	return string.format("d%x%x", serverTime, math.random(0, 0xFFFF))
end

local function CreateGame(gameID, challenger, opponent, startingMax, profileKey)
	startingMax = NormalizeStartingMax(startingMax)
	return {
		id = gameID,
		challenger = challenger,
		opponent = opponent,
		startingMax = startingMax,
		currentMax = startingMax,
		turn = CHALLENGER,
		sequence = 0,
		status = "ACTIVE",
		history = {},
		profileKey = profileKey,
		startedAt = GetServerTime and GetServerTime() or 0,
	}
end

local function IsValidSavedDeathRollGame(game, profileKey)
	if type(game) ~= "table" or type(game.id) ~= "string" or not game.id:match("^[%w%-]+$")
		or game.profileKey ~= profileKey
		or not (game.status == "ACTIVE" or game.status == "FINISHED")
		or not (game.turn == CHALLENGER or game.turn == OPPONENT)
		or not NamesMatch(game.challenger, GetLocalPlayerName())
			and not NamesMatch(game.opponent, GetLocalPlayerName()) then
		return false
	end
	game.startingMax = NormalizeStartingMax(game.startingMax)
	game.currentMax = math.floor(tonumber(game.currentMax) or 0)
	game.sequence = math.max(0, math.floor(tonumber(game.sequence) or 0))
	game.history = type(game.history) == "table" and game.history or {}
	return game.currentMax >= 1 and game.currentMax <= game.startingMax
end

local function GetSeatPlayer(game, seat)
	if not game then
		return nil
	end
	return seat == CHALLENGER and game.challenger or game.opponent
end

local function GetPlayerSeat(game, playerName)
	if not game then
		return nil
	end
	if NamesMatch(game.challenger, playerName) then
		return CHALLENGER
	elseif NamesMatch(game.opponent, playerName) then
		return OPPONENT
	end
	return nil
end

local function DeterministicRoll(game, sequence, upperBound)
	upperBound = math.max(1, math.floor(tonumber(upperBound) or 1))
	local seed = table.concat({
		game.id,
		NormalizePlayerName(game.challenger),
		NormalizePlayerName(game.opponent),
		tostring(sequence),
		tostring(upperBound),
	}, "|")
	return (HashString(seed) % upperBound) + 1
end

local function GameStateString(game)
	return table.concat({
		game.id or "",
		tostring(game.sequence or 0),
		tostring(game.currentMax or 0),
		game.turn or "",
		game.status or "ACTIVE",
		game.winnerSeat or "",
		game.loserSeat or "",
	}, ":")
end

local function StateChecksum(game)
	return HashString(GameStateString(game))
end

local function SnapshotGame(game)
	return {
		currentMax = game.currentMax,
		turn = game.turn,
		sequence = game.sequence,
		status = game.status,
		winnerSeat = game.winnerSeat,
		loserSeat = game.loserSeat,
		finishReason = game.finishReason,
		historyLength = #(game.history or {}),
		lastRoll = game.lastRoll,
	}
end

local function RestoreGame(game, snapshot)
	game.currentMax = snapshot.currentMax
	game.turn = snapshot.turn
	game.sequence = snapshot.sequence
	game.status = snapshot.status
	game.winnerSeat = snapshot.winnerSeat
	game.loserSeat = snapshot.loserSeat
	game.finishReason = snapshot.finishReason
	game.lastRoll = snapshot.lastRoll
	while #(game.history or {}) > snapshot.historyLength do
		table.remove(game.history)
	end
end

local function ApplyRoll(game, result, rollerSeat, upperBound)
	result = math.floor(tonumber(result) or 0)
	upperBound = math.floor(tonumber(upperBound) or 0)
	if game.status ~= "ACTIVE" then
		return nil, "That Death Roll game has already ended."
	end
	if rollerSeat ~= game.turn then
		return nil, "It is not that player's turn."
	end
	if upperBound ~= game.currentMax or result < 1 or result > upperBound then
		return nil, "That Death Roll result is invalid."
	end

	game.sequence = (game.sequence or 0) + 1
	local roll = {
		sequence = game.sequence,
		seat = rollerSeat,
		player = GetSeatPlayer(game, rollerSeat),
		upperBound = upperBound,
		result = result,
	}
	game.currentMax = result
	game.lastRoll = roll
	game.history = game.history or {}
	table.insert(game.history, roll)
	if result == 1 then
		game.status = "FINISHED"
		game.loserSeat = rollerSeat
		game.winnerSeat = OtherSeat(rollerSeat)
		game.finishReason = "rolled 1"
	else
		game.turn = OtherSeat(rollerSeat)
	end
	return roll
end

function GuildPing:GetLocalDeathRollPlayerName()
	return GetLocalPlayerName()
end

function GuildPing:GetDeathRollSeatPlayer(game, seat)
	return GetSeatPlayer(game or self.activeDeathRollGame, seat)
end

function GuildPing:GetLocalDeathRollSeat(game)
	game = game or self.activeDeathRollGame
	if game and game.practice then
		return game.turn
	end
	return GetPlayerSeat(game, GetLocalPlayerName())
end

function GuildPing:GetDeathRollTurnPlayer(game)
	game = game or self.activeDeathRollGame
	return game and GetSeatPlayer(game, game.turn) or nil
end

function GuildPing:GetDeathRollOpponent(game)
	game = game or self.activeDeathRollGame
	if not game then
		return nil
	end
	if game.practice then
		return "Practice board"
	end
	local localSeat = GetPlayerSeat(game, GetLocalPlayerName())
	return localSeat and GetSeatPlayer(game, OtherSeat(localSeat)) or nil
end

function GuildPing:GetDeathRollDeterministicResult(game, sequence, upperBound)
	return DeterministicRoll(game or self.activeDeathRollGame, sequence, upperBound)
end

function GuildPing:GetDeathRollStateChecksum(game)
	return StateChecksum(game or self.activeDeathRollGame)
end

function GuildPing:IsDeathRollGameForCurrentGuild(game)
	game = game or self.activeDeathRollGame
	if not game then
		return false
	end
	local _, profileKey = self:GetProfile()
	return game.profileKey == profileKey
end

function GuildPing:RefreshDeathRollUI()
	if self.activeDeathRollGame then
		local game = self.activeDeathRollGame
		local localSeat = self:GetLocalDeathRollSeat(game)
		if game.status == "FINISHED" then
			local result = game.winnerSeat == nil and "DRAW" or game.winnerSeat == localSeat and "WIN" or "LOSS"
			self:RecordGameResult("deathRoll", game, result, self:GetDeathRollOpponent(game), {
				reason = game.finishReason or "game complete",
				resigned = game.finishReason == "resignation",
			})
		end
		self:PersistGameState("deathRoll", game)
		if localSeat and game.turn == localSeat then
			self:NotifyGameTurn(
				"deathRoll",
				game,
				tostring(game.sequence or 0) .. ":" .. tostring(game.turn),
				"Your turn in Guild Ping Death Roll",
				self.deathRollGameFrame and self.deathRollGameFrame:IsShown()
			)
		end
	end
	if self.RefreshDeathRollGame and self.deathRollGameFrame and self.deathRollGameFrame:IsShown() then
		self:RefreshDeathRollGame()
	end
	if self.RefreshDeathRollLobby and self.deathRollLobby and self.deathRollLobby:IsShown() then
		self:RefreshDeathRollLobby()
	end
	if self.RefreshGamesIndicators then
		self:RefreshGamesIndicators()
	end
end

function GuildPing:SendDeathRollAddonMessage(message)
	if not self.deathRollInitialized then
		return nil, "Death Roll messaging is not available."
	end
	if not IsInGuild or not IsInGuild() then
		return nil, "You must be in a guild to play Guild Ping Death Roll."
	end
	local succeeded, result = pcall(C_ChatInfo.SendAddonMessage, MESSAGE_PREFIX, message, "GUILD")
	if not succeeded or result == false then
		return nil, "WoW could not send the Death Roll message."
	end
	return true
end

function GuildPing:ResolveDeathRollGuildMember(input)
	input = Trim(input):gsub("^@", "")
	if input == "" then
		return nil, "Enter the character name of a guild member."
	end
	if not GetNumGuildMembers or not GetGuildRosterInfo then
		return nil, "The guild roster is not available yet."
	end

	local normalizedInput = NormalizePlayerName(input)
	local inputBase = PlayerBaseName(input)
	local baseMatches = {}
	for index = 1, tonumber(GetNumGuildMembers()) or 0 do
		local memberName, _, _, _, _, _, _, _, isOnline = GetGuildRosterInfo(index)
		if memberName then
			local normalizedMember = NormalizePlayerName(memberName)
			if normalizedMember == normalizedInput then
				if isOnline == false then
					return nil, memberName .. " is currently offline."
				end
				return memberName
			elseif PlayerBaseName(memberName) == inputBase then
				table.insert(baseMatches, { name = memberName, online = isOnline ~= false })
			end
		end
	end

	if #baseMatches == 1 then
		if not baseMatches[1].online then
			return nil, baseMatches[1].name .. " is currently offline."
		end
		return baseMatches[1].name
	elseif #baseMatches > 1 then
		return nil, "More than one guild member has that name. Include the realm name."
	end
	return nil, "That character was not found in the current guild roster."
end

function GuildPing:CleanupDeathRollInvites()
	local currentTime = GetTime and GetTime() or 0
	local invites = self.deathRollInvites or {}
	for index = #invites, 1, -1 do
		if currentTime - (invites[index].receivedAt or 0) > CHALLENGE_TIMEOUT then
			table.remove(invites, index)
		end
	end
	if self.outgoingDeathRollChallenge
		and currentTime - (self.outgoingDeathRollChallenge.sentAt or 0) > CHALLENGE_TIMEOUT then
		self.outgoingDeathRollChallenge = nil
	end
end

function GuildPing:GetDeathRollInvites()
	self:CleanupDeathRollInvites()
	self.deathRollInvites = self.deathRollInvites or {}
	return self.deathRollInvites
end

function GuildPing:GetDeathRollInviteCount()
	return #self:GetDeathRollInvites()
end

function GuildPing:StartDeathRollPractice(startingMax)
	local localName = GetLocalPlayerName()
	local _, profileKey = self:GetProfile()
	local game = CreateGame(CreateGameID(), localName, localName, startingMax, profileKey)
	game.practice = true
	self.activeDeathRollGame = game
	self.outgoingDeathRollChallenge = nil
	self:RefreshDeathRollUI()
	return game
end

function GuildPing:ChallengeDeathRollPlayer(targetInput, startingMax)
	self:CleanupDeathRollInvites()
	if self.activeDeathRollGame and self.activeDeathRollGame.status == "ACTIVE" then
		return nil, "Finish or resign from your current Death Roll game first."
	end
	if self.outgoingDeathRollChallenge then
		return nil, "You already have a Death Roll challenge waiting for a response."
	end
	startingMax = NormalizeStartingMax(startingMax)

	local requestedName = Trim(targetInput):gsub("^@", "")
	if NamesMatch(requestedName, GetLocalPlayerName()) then
		local game = self:StartDeathRollPractice(startingMax)
		return { practice = true, game = game, target = GetLocalPlayerName(), startingMax = startingMax }
	end

	local target, errorMessage = self:ResolveDeathRollGuildMember(targetInput)
	if not target then
		return nil, errorMessage
	end
	if NamesMatch(target, GetLocalPlayerName()) then
		local game = self:StartDeathRollPractice(startingMax)
		return { practice = true, game = game, target = GetLocalPlayerName(), startingMax = startingMax }
	end

	local gameID = CreateGameID()
	local sent, sendError = self:SendDeathRollAddonMessage(table.concat({
		"I", PROTOCOL_VERSION, gameID, target, tostring(startingMax),
	}, "|"))
	if not sent then
		return nil, sendError
	end
	self.outgoingDeathRollChallenge = {
		id = gameID,
		target = target,
		startingMax = startingMax,
		sentAt = GetTime and GetTime() or 0,
	}
	self:RefreshDeathRollUI()
	return self.outgoingDeathRollChallenge
end

function GuildPing:PlayDeathRollAgain()
	local game = self.activeDeathRollGame
	if not game or game.status ~= "FINISHED" then
		return nil, "Finish the current Death Roll game before starting a rematch."
	end
	if not self:IsDeathRollGameForCurrentGuild(game) then
		return nil, "This Death Roll game belongs to a different guild."
	end
	local target = game.practice and self:GetLocalDeathRollPlayerName() or self:GetDeathRollOpponent(game)
	if not target then return nil, "The previous Death Roll opponent could not be found." end
	return self:ChallengeDeathRollPlayer(target, game.startingMax)
end

function GuildPing:ResumeDeathRollGame(delay)
	local game = self.activeDeathRollGame
	if not game then
		return nil, "There is no saved Death Roll game to resume."
	end
	if not self:IsDeathRollGameForCurrentGuild(game) then
		return nil, "This Death Roll game belongs to a different guild."
	end
	if game.status ~= "ACTIVE" or game.practice then
		game.persistenceState = nil
		self:PersistGameState("deathRoll", game)
		self:RefreshDeathRollUI()
		return true
	end
	local opponent = self:GetDeathRollOpponent(game)
	if not opponent then
		return nil, "The previous Death Roll opponent could not be found."
	end
	self:BeginGameReconnect("deathRoll", game, function()
		local sent, errorMessage = self:RequestDeathRollSync(game, opponent)
		if not sent and game.persistenceState == "RECONNECTING" then
			game.persistenceState = "STALE"
			self:PersistGameState("deathRoll", game)
			if errorMessage then self:Print(errorMessage) end
			self:RefreshDeathRollUI()
		end
	end, delay)
	self:RefreshDeathRollUI()
	return true
end

function GuildPing:AbandonDeathRollGame()
	local game = self.activeDeathRollGame
	if not game then
		return nil, "There is no Death Roll game to abandon."
	end
	if game.status == "ACTIVE" and not game.practice then
		self:SendDeathRollAddonMessage(table.concat({
			"R", PROTOCOL_VERSION, game.id, self:GetDeathRollOpponent(game),
		}, "|"))
		game.status = "FINISHED"
		game.loserSeat = self:GetLocalDeathRollSeat(game)
		game.winnerSeat = OtherSeat(game.loserSeat)
		game.finishReason = "resignation"
		self:RecordGameResult("deathRoll", game, "LOSS", self:GetDeathRollOpponent(game), {
			reason = "abandoned",
			resigned = true,
		})
	end
	self.activeDeathRollGame = nil
	self:ClearSavedGameState("deathRoll")
	self:RefreshDeathRollUI()
	return true
end

function GuildPing:GetDeathRollInvite(inviteID)
	for index, invite in ipairs(self:GetDeathRollInvites()) do
		if invite.id == inviteID then
			return invite, index
		end
	end
	return nil
end

function GuildPing:AcceptDeathRollChallenge(inviteID)
	if self.activeDeathRollGame and self.activeDeathRollGame.status == "ACTIVE" then
		return nil, "Finish or resign from your current Death Roll game first."
	end
	local invite, index = self:GetDeathRollInvite(inviteID)
	if not invite then
		return nil, "That Death Roll challenge is no longer available."
	end
	local sent, errorMessage = self:SendDeathRollAddonMessage(table.concat({
		"A", PROTOCOL_VERSION, invite.id, invite.sender,
	}, "|"))
	if not sent then
		return nil, errorMessage
	end
	local _, profileKey = self:GetProfile()
	self.activeDeathRollGame = CreateGame(
		invite.id,
		invite.sender,
		GetLocalPlayerName(),
		invite.startingMax,
		profileKey
	)
	table.remove(self.deathRollInvites, index)
	self.outgoingDeathRollChallenge = nil
	self:RefreshDeathRollUI()
	return self.activeDeathRollGame
end

function GuildPing:DeclineDeathRollChallenge(inviteID)
	local invite, index = self:GetDeathRollInvite(inviteID)
	if not invite then
		return false
	end
	self:SendDeathRollAddonMessage(table.concat({
		"D", PROTOCOL_VERSION, invite.id, invite.sender,
	}, "|"))
	table.remove(self.deathRollInvites, index)
	self:RefreshDeathRollUI()
	return true
end

function GuildPing:RollDeathRoll()
	local game = self.activeDeathRollGame
	if not game or game.status ~= "ACTIVE" then
		return nil, "There is no active Death Roll game."
	end
	if self:IsGameWaitingForResume(game) then
		return nil, "Resume this Death Roll game before rolling."
	end
	if not self:IsDeathRollGameForCurrentGuild(game) then
		return nil, "This Death Roll game belongs to a different guild."
	end
	local localSeat = self:GetLocalDeathRollSeat(game)
	if not localSeat or localSeat ~= game.turn then
		return nil, "Wait for your opponent's roll."
	end

	local upperBound = game.currentMax
	local nextSequence = (game.sequence or 0) + 1
	local result = DeterministicRoll(game, nextSequence, upperBound)
	local snapshot = SnapshotGame(game)
	local roll, rollError = ApplyRoll(game, result, localSeat, upperBound)
	if not roll then
		return nil, rollError
	end

	if not game.practice then
		local sent, sendError = self:SendDeathRollAddonMessage(table.concat({
			"L",
			PROTOCOL_VERSION,
			game.id,
			self:GetDeathRollOpponent(game),
			tostring(game.sequence),
			tostring(upperBound),
			tostring(result),
			tostring(StateChecksum(game)),
		}, "|"))
		if not sent then
			RestoreGame(game, snapshot)
			return nil, sendError
		end
	end

	if self.BeginDeathRollAnimation then
		self:BeginDeathRollAnimation(roll)
	end
	self:RefreshDeathRollUI()
	return roll
end

function GuildPing:RequestDeathRollSync(game, target)
	game = game or self.activeDeathRollGame
	if not game then
		return nil, "There is no Death Roll game to synchronize."
	end
	return self:SendDeathRollAddonMessage(table.concat({
		"Q", PROTOCOL_VERSION, game.id, target or self:GetDeathRollOpponent(game),
	}, "|"))
end

function GuildPing:SendDeathRollSync(game, target)
	local statusCode = game.status == "FINISHED" and "F" or "A"
	local turnCode = game.turn == CHALLENGER and "C" or "O"
	local winnerCode = game.winnerSeat == CHALLENGER and "C" or game.winnerSeat == OPPONENT and "O" or "N"
	local loserCode = game.loserSeat == CHALLENGER and "C" or game.loserSeat == OPPONENT and "O" or "N"
	self:SendDeathRollAddonMessage(table.concat({
		"S",
		PROTOCOL_VERSION,
		game.id,
		target,
		tostring(game.sequence or 0),
		tostring(game.currentMax),
		turnCode,
		statusCode,
		winnerCode,
		loserCode,
		tostring(StateChecksum(game)),
	}, "|"))
end

function GuildPing:ResignDeathRollGame()
	local game = self.activeDeathRollGame
	if not game or game.status ~= "ACTIVE" then
		return nil, "There is no active Death Roll game to resign from."
	end
	if game.practice then
		game.status = "FINISHED"
		game.winnerSeat = nil
		game.loserSeat = nil
		game.finishReason = "practice ended"
		self:RefreshDeathRollUI()
		return true
	end
	local localSeat = GetPlayerSeat(game, GetLocalPlayerName())
	local sent, errorMessage = self:SendDeathRollAddonMessage(table.concat({
		"R", PROTOCOL_VERSION, game.id, self:GetDeathRollOpponent(game),
	}, "|"))
	if not sent then
		return nil, errorMessage
	end
	game.status = "FINISHED"
	game.loserSeat = localSeat
	game.winnerSeat = OtherSeat(localSeat)
	game.finishReason = "resignation"
	self:RefreshDeathRollUI()
	return true
end

function GuildPing:HandleDeathRollMessage(prefix, message, channel, sender)
	if not self.deathRollInitialized
		or prefix ~= MESSAGE_PREFIX
		or channel ~= "GUILD"
		or type(message) ~= "string"
		or type(sender) ~= "string"
		or NamesMatch(sender, GetLocalPlayerName()) then
		return
	end
	self:CleanupDeathRollInvites()
	local messageType, protocol = message:match("^([^|]+)|([^|]+)|")
	if protocol ~= PROTOCOL_VERSION then
		return
	end
	local localName = GetLocalPlayerName()

	if messageType == "I" then
		local gameID, target, startingMax = message:match("^I|1|([%w%-]+)|([^|]+)|(%d+)$")
		startingMax = tonumber(startingMax)
		if not gameID or not NamesMatch(target, localName) or not startingMax
			or startingMax < MIN_STARTING_MAX or startingMax > MAX_STARTING_MAX then
			return
		end
		local invites = self:GetDeathRollInvites()
		local currentTime = GetTime and GetTime() or 0
		self.deathRollInviteSenderCooldowns = self.deathRollInviteSenderCooldowns or {}
		local senderKey = NormalizePlayerName(sender)
		local lastInviteAt = self.deathRollInviteSenderCooldowns[senderKey]
		if lastInviteAt and currentTime - lastInviteAt < INVITE_COOLDOWN then
			return
		end
		for _, invite in ipairs(invites) do
			if invite.id == gameID then
				return
			end
		end
		if #invites >= MAX_INVITES then
			table.remove(invites, 1)
		end
		self.deathRollInviteSenderCooldowns[senderKey] = currentTime
		table.insert(invites, {
			id = gameID,
			sender = sender,
			startingMax = startingMax,
			receivedAt = currentTime,
		})
		self:Print(sender .. " challenged you to Death Roll (1–" .. startingMax .. "). Open /gp games to respond.")
		self:RefreshDeathRollUI()
	elseif messageType == "A" then
		local gameID, target = message:match("^A|1|([%w%-]+)|([^|]+)$")
		local outgoing = self.outgoingDeathRollChallenge
		if not gameID or not NamesMatch(target, localName) or not outgoing
			or outgoing.id ~= gameID or not NamesMatch(outgoing.target, sender) then
			return
		end
		if self.activeDeathRollGame and self.activeDeathRollGame.status == "ACTIVE" then
			self.outgoingDeathRollChallenge = nil
			return
		end
		local _, profileKey = self:GetProfile()
		self.activeDeathRollGame = CreateGame(gameID, localName, sender, outgoing.startingMax, profileKey)
		self.outgoingDeathRollChallenge = nil
		self:Print(sender .. " accepted your Death Roll challenge. You roll first.")
		self:RefreshDeathRollUI()
	elseif messageType == "D" then
		local gameID, target = message:match("^D|1|([%w%-]+)|([^|]+)$")
		local outgoing = self.outgoingDeathRollChallenge
		if gameID and NamesMatch(target, localName) and outgoing and outgoing.id == gameID then
			self.outgoingDeathRollChallenge = nil
			self:Print(sender .. " declined your Death Roll challenge.")
			self:RefreshDeathRollUI()
		end
	elseif messageType == "L" then
		local gameID, target, sequence, upperBound, result, checksum = message:match(
			"^L|1|([%w%-]+)|([^|]+)|(%d+)|(%d+)|(%d+)|(%d+)$"
		)
		sequence, upperBound, result, checksum = tonumber(sequence), tonumber(upperBound), tonumber(result), tonumber(checksum)
		local game = self.activeDeathRollGame
		if not gameID or not game or game.id ~= gameID or not NamesMatch(target, localName)
			or not NamesMatch(sender, GetSeatPlayer(game, game.turn)) then
			return
		end
		if sequence ~= (game.sequence or 0) + 1 or upperBound ~= game.currentMax
			or result ~= DeterministicRoll(game, sequence, upperBound) then
			self:Print("Death Roll verification failed; requesting the current game state.")
			self:RequestDeathRollSync(game, sender)
			return
		end
		self:CompleteGameReconnect("deathRoll", game)
		local snapshot = SnapshotGame(game)
		local roll = ApplyRoll(game, result, game.turn, upperBound)
		if not roll or StateChecksum(game) ~= checksum then
			RestoreGame(game, snapshot)
			self:Print("Death Roll state was out of sync; requesting the current game state.")
			self:RequestDeathRollSync(game, sender)
			return
		end
		if self.BeginDeathRollAnimation then
			self:BeginDeathRollAnimation(roll)
		end
		if game.status == "FINISHED" then
			self:Print(sender .. " rolled 1 and lost your Death Roll game.")
		end
		self:RefreshDeathRollUI()
	elseif messageType == "Q" then
		local gameID, target = message:match("^Q|1|([%w%-]+)|([^|]+)$")
		local game = self.activeDeathRollGame
		if gameID and game and game.id == gameID and NamesMatch(target, localName)
			and NamesMatch(sender, self:GetDeathRollOpponent(game)) then
			self:CompleteGameReconnect("deathRoll", game)
			self:SendDeathRollSync(game, sender)
		end
	elseif messageType == "S" then
		local gameID, target, sequence, currentMax, turnCode, statusCode, winnerCode, loserCode, checksum = message:match(
			"^S|1|([%w%-]+)|([^|]+)|(%d+)|(%d+)|([CO])|([AF])|([CON])|([CON])|(%d+)$"
		)
		sequence, currentMax, checksum = tonumber(sequence), tonumber(currentMax), tonumber(checksum)
		local game = self.activeDeathRollGame
		if not gameID or not game or game.id ~= gameID or not NamesMatch(target, localName)
			or not NamesMatch(sender, self:GetDeathRollOpponent(game)) or sequence < (game.sequence or 0) then
			return
		end
		local synchronized = {
			id = game.id,
			sequence = sequence,
			currentMax = currentMax,
			turn = turnCode == "C" and CHALLENGER or OPPONENT,
			status = statusCode == "F" and "FINISHED" or "ACTIVE",
			winnerSeat = winnerCode == "C" and CHALLENGER or winnerCode == "O" and OPPONENT or nil,
			loserSeat = loserCode == "C" and CHALLENGER or loserCode == "O" and OPPONENT or nil,
		}
		if not currentMax or currentMax < 1 or StateChecksum(synchronized) ~= checksum then
			return
		end
		game.sequence = synchronized.sequence
		game.currentMax = synchronized.currentMax
		game.turn = synchronized.turn
		game.status = synchronized.status
		game.winnerSeat = synchronized.winnerSeat
		game.loserSeat = synchronized.loserSeat
		game.finishReason = game.status == "FINISHED" and "rolled 1" or nil
		game.lastRoll = nil
		game.history = {}
		self:CompleteGameReconnect("deathRoll", game)
		self:Print("The Death Roll game was synchronized with " .. sender .. ".")
		self:RefreshDeathRollUI()
	elseif messageType == "R" then
		local gameID, target = message:match("^R|1|([%w%-]+)|([^|]+)$")
		local game = self.activeDeathRollGame
		if gameID and game and game.id == gameID and game.status == "ACTIVE"
			and NamesMatch(target, localName) and NamesMatch(sender, self:GetDeathRollOpponent(game)) then
			self:CompleteGameReconnect("deathRoll", game)
			local senderSeat = GetPlayerSeat(game, sender)
			game.status = "FINISHED"
			game.loserSeat = senderSeat
			game.winnerSeat = OtherSeat(senderSeat)
			game.finishReason = "opponent resigned"
			self:Print(sender .. " resigned from your Death Roll game.")
			self:RefreshDeathRollUI()
		end
	end
end

function GuildPing:InitializeDeathRoll()
	if self.deathRollInitialized
		or not C_ChatInfo
		or not C_ChatInfo.RegisterAddonMessagePrefix
		or not C_ChatInfo.SendAddonMessage then
		return
	end
	if not C_ChatInfo.RegisterAddonMessagePrefix(MESSAGE_PREFIX) then
		return
	end
	self.deathRollInitialized = true
	self.deathRollInvites = self.deathRollInvites or {}
	local restoredGame, restoreReason = self:RestoreSavedGameState("deathRoll")
	local _, profileKey = self:GetProfile()
	if restoredGame and IsValidSavedDeathRollGame(restoredGame, profileKey) then
		self.activeDeathRollGame = restoredGame
		if restoredGame.status == "ACTIVE" then
			if restoredGame.practice then
				restoredGame.persistenceState = "RESTORED"
				self:PersistGameState("deathRoll", restoredGame)
			else
				self:ResumeDeathRollGame(1.25)
			end
		end
	elseif restoredGame then
		self:ClearSavedGameState("deathRoll")
	elseif restoreReason == "EXPIRED" then
		self:Print("An abandoned saved Death Roll game expired and was removed.")
	end
	if C_GuildInfo and C_GuildInfo.GuildRoster then
		C_GuildInfo.GuildRoster()
	end
end

GuildPing.DEATH_ROLL_CHALLENGER = CHALLENGER
GuildPing.DEATH_ROLL_OPPONENT = OPPONENT
GuildPing.DEATH_ROLL_MINIMUM = MIN_STARTING_MAX
GuildPing.DEATH_ROLL_MAXIMUM = MAX_STARTING_MAX
