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

local MESSAGE_PREFIX = "GuildPingPoker"
local PROTOCOL_VERSION = "1"
local CHALLENGE_TIMEOUT = 300
local INVITE_COOLDOWN = 15
local MAX_INVITES = 3
local MIN_STARTING_STACK = 200
local MAX_STARTING_STACK = 10000
local DEFAULT_STARTING_STACK = 1000
local SMALL_BLIND = 10
local BIG_BLIND = 20
local CHALLENGER = "CHALLENGER"
local OPPONENT = "OPPONENT"

local ROUND_CODES = {
	PREFLOP = "P",
	FLOP = "F",
	TURN = "T",
	RIVER = "R",
	SHOWDOWN = "S",
}
local CODE_ROUNDS = {
	P = "PREFLOP",
	F = "FLOP",
	T = "TURN",
	R = "RIVER",
	S = "SHOWDOWN",
}
local ACTION_CODES = {
	FOLD = "F",
	CHECK = "K",
	CALL = "C",
	RAISE = "R",
}
local CODE_ACTIONS = {
	F = "FOLD",
	K = "CHECK",
	C = "CALL",
	R = "RAISE",
}

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 SeatCode(seat)
	return seat == CHALLENGER and "C" or seat == OPPONENT and "O" or "N"
end

local function CodeSeat(code)
	return code == "C" and CHALLENGER or code == "O" and OPPONENT or nil
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 NormalizeStartingStack(value)
	value = math.floor(tonumber(value) or DEFAULT_STARTING_STACK)
	return math.max(MIN_STARTING_STACK, math.min(MAX_STARTING_STACK, value))
end

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

local function CopyArray(source)
	local result = {}
	for index, value in ipairs(source or {}) do
		result[index] = value
	end
	return result
end

local function SplitMessage(message)
	local fields = {}
	for value in (message .. "|"):gmatch("(.-)|") do
		table.insert(fields, value)
	end
	return fields
end

local function CreateGame(gameID, challenger, opponent, startingStack, profileKey)
	startingStack = NormalizeStartingStack(startingStack)
	return {
		id = gameID,
		challenger = challenger,
		opponent = opponent,
		startingStack = startingStack,
		smallBlind = SMALL_BLIND,
		bigBlind = BIG_BLIND,
		stacks = {
			[CHALLENGER] = startingStack,
			[OPPONENT] = startingStack,
		},
		roundBets = {
			[CHALLENGER] = 0,
			[OPPONENT] = 0,
		},
		acted = {
			[CHALLENGER] = false,
			[OPPONENT] = false,
		},
		holeCards = {
			[CHALLENGER] = {},
			[OPPONENT] = {},
		},
		board = {},
		pot = 0,
		currentBet = 0,
		minRaise = BIG_BLIND,
		sequence = 0,
		handNumber = 0,
		handStatus = "WAITING",
		status = "ACTIVE",
		actionLog = {},
		profileKey = profileKey,
		startedAt = GetServerTime and GetServerTime() or 0,
	}
end

local function IsValidSavedPokerGame(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.handStatus == "WAITING" or game.handStatus == "ACTIVE" or game.handStatus == "COMPLETE")
		or not NamesMatch(game.challenger, GetLocalPlayerName())
			and not NamesMatch(game.opponent, GetLocalPlayerName())
		or type(game.stacks) ~= "table" or type(game.roundBets) ~= "table"
		or type(game.holeCards) ~= "table" or type(game.board) ~= "table" then
		return false
	end
	local startingStack = math.floor(tonumber(game.startingStack) or 0)
	local challengerStack = math.floor(tonumber(game.stacks[CHALLENGER]) or -1)
	local opponentStack = math.floor(tonumber(game.stacks[OPPONENT]) or -1)
	if startingStack < MIN_STARTING_STACK or startingStack > MAX_STARTING_STACK
		or challengerStack < 0 or opponentStack < 0 then
		return false
	end
	game.startingStack = startingStack
	game.sequence = math.max(0, math.floor(tonumber(game.sequence) or 0))
	game.handNumber = math.max(0, math.floor(tonumber(game.handNumber) or 0))
	game.actionLog = type(game.actionLog) == "table" and game.actionLog or {}
	return true
end

local function BuildDeck()
	local deck = {}
	local ranks = "23456789TJQKA"
	local suits = "cdhs"
	for suitIndex = 1, #suits do
		for rankIndex = 1, #ranks do
			table.insert(deck, ranks:sub(rankIndex, rankIndex) .. suits:sub(suitIndex, suitIndex))
		end
	end
	for index = #deck, 2, -1 do
		local swapIndex = math.random(1, index)
		deck[index], deck[swapIndex] = deck[swapIndex], deck[index]
	end
	return deck
end

local function DrawCard(game)
	return table.remove(game.deck, 1)
end

local function BurnCard(game)
	DrawCard(game)
end

local function RankValue(card)
	local position = ("23456789TJQKA"):find(card:sub(1, 1), 1, true)
	return position and position + 1 or 0
end

local function IsValidCard(card)
	return type(card) == "string"
		and #card == 2
		and RankValue(card) >= 2
		and ("cdhs"):find(card:sub(2, 2), 1, true) ~= nil
end

local function CompareScores(left, right)
	for index = 1, math.max(#left, #right) do
		local leftValue = left[index] or 0
		local rightValue = right[index] or 0
		if leftValue > rightValue then
			return 1
		elseif leftValue < rightValue then
			return -1
		end
	end
	return 0
end

local function Descending(left, right)
	return left > right
end

local function EvaluateFive(cards)
	local ranks = {}
	local counts = {}
	local flush = true
	local firstSuit = cards[1]:sub(2, 2)
	for _, card in ipairs(cards) do
		local rank = RankValue(card)
		table.insert(ranks, rank)
		counts[rank] = (counts[rank] or 0) + 1
		if card:sub(2, 2) ~= firstSuit then
			flush = false
		end
	end
	table.sort(ranks, Descending)

	local unique = {}
	for _, rank in ipairs(ranks) do
		if not unique[#unique] or unique[#unique] ~= rank then
			table.insert(unique, rank)
		end
	end
	local straightHigh
	if #unique == 5 and unique[1] - unique[5] == 4 then
		straightHigh = unique[1]
	elseif #unique == 5 and unique[1] == 14 and unique[2] == 5
		and unique[3] == 4 and unique[4] == 3 and unique[5] == 2 then
		straightHigh = 5
	end

	local groups = {}
	for rank, count in pairs(counts) do
		table.insert(groups, { rank = rank, count = count })
	end
	table.sort(groups, function(left, right)
		if left.count == right.count then
			return left.rank > right.rank
		end
		return left.count > right.count
	end)

	if straightHigh and flush then
		return { 8, straightHigh }, "Straight flush"
	elseif groups[1].count == 4 then
		return { 7, groups[1].rank, groups[2].rank }, "Four of a kind"
	elseif groups[1].count == 3 and groups[2].count == 2 then
		return { 6, groups[1].rank, groups[2].rank }, "Full house"
	elseif flush then
		return { 5, ranks[1], ranks[2], ranks[3], ranks[4], ranks[5] }, "Flush"
	elseif straightHigh then
		return { 4, straightHigh }, "Straight"
	elseif groups[1].count == 3 then
		local kickers = {}
		for _, rank in ipairs(ranks) do
			if rank ~= groups[1].rank then table.insert(kickers, rank) end
		end
		return { 3, groups[1].rank, kickers[1], kickers[2] }, "Three of a kind"
	elseif groups[1].count == 2 and groups[2].count == 2 then
		local highPair = math.max(groups[1].rank, groups[2].rank)
		local lowPair = math.min(groups[1].rank, groups[2].rank)
		local kicker = groups[3].rank
		return { 2, highPair, lowPair, kicker }, "Two pair"
	elseif groups[1].count == 2 then
		local kickers = {}
		for _, rank in ipairs(ranks) do
			if rank ~= groups[1].rank then table.insert(kickers, rank) end
		end
		return { 1, groups[1].rank, kickers[1], kickers[2], kickers[3] }, "One pair"
	end
	return { 0, ranks[1], ranks[2], ranks[3], ranks[4], ranks[5] }, "High card"
end

local function EvaluateSeven(cards)
	if #cards ~= 7 then
		return nil, "Incomplete hand"
	end
	local bestScore, bestName
	for first = 1, 3 do
		for second = first + 1, 4 do
			for third = second + 1, 5 do
				for fourth = third + 1, 6 do
					for fifth = fourth + 1, 7 do
						local score, name = EvaluateFive({
							cards[first], cards[second], cards[third], cards[fourth], cards[fifth],
						})
						if not bestScore or CompareScores(score, bestScore) > 0 then
							bestScore, bestName = score, name
						end
					end
				end
			end
		end
	end
	return bestScore, bestName
end

local function TablePot(game)
	return (game.pot or 0)
		+ (game.roundBets and game.roundBets[CHALLENGER] or 0)
		+ (game.roundBets and game.roundBets[OPPONENT] or 0)
end

local function CommitChips(game, seat, amount)
	amount = math.max(0, math.min(math.floor(tonumber(amount) or 0), game.stacks[seat] or 0))
	game.stacks[seat] = game.stacks[seat] - amount
	game.roundBets[seat] = (game.roundBets[seat] or 0) + amount
	return amount
end

local function MoveBetsToPot(game)
	game.pot = TablePot(game)
	game.roundBets[CHALLENGER] = 0
	game.roundBets[OPPONENT] = 0
	game.currentBet = 0
end

local function RefundUncalledChips(game)
	local challengerBet = game.roundBets[CHALLENGER] or 0
	local opponentBet = game.roundBets[OPPONENT] or 0
	if challengerBet == opponentBet then
		return
	end
	local highSeat = challengerBet > opponentBet and CHALLENGER or OPPONENT
	local lowBet = math.min(challengerBet, opponentBet)
	local refund = game.roundBets[highSeat] - lowBet
	game.roundBets[highSeat] = lowBet
	game.stacks[highSeat] = game.stacks[highSeat] + refund
	game.currentBet = lowBet
end

local function AddActionLog(game, seat, action, amount)
	game.lastActor = seat
	game.lastAction = action
	game.lastAmount = math.floor(tonumber(amount) or 0)
	game.actionLog = game.actionLog or {}
	table.insert(game.actionLog, {
		sequence = game.sequence,
		seat = seat,
		action = action,
		amount = game.lastAmount,
	})
	while #game.actionLog > 12 do
		table.remove(game.actionLog, 1)
	end
	local history = game.currentHandHistory
	if history and not history.archived then
		table.insert(history.events, {
			sequence = game.sequence,
			round = game.round,
			seat = seat,
			action = action,
			amount = game.lastAmount,
		})
		history.lastActionSequence = game.sequence
	end
end

local function StartPokerHandHistory(game, startingStacks)
	startingStacks = startingStacks or game.stacks
	game.currentHandHistory = {
		gameID = game.id,
		handNumber = game.handNumber,
		button = game.button,
		challenger = game.challenger,
		opponent = game.opponent,
		startedAt = GetServerTime and GetServerTime() or 0,
		startingStacks = {
			[CHALLENGER] = math.max(0, math.floor(tonumber(startingStacks[CHALLENGER]) or 0)),
			[OPPONENT] = math.max(0, math.floor(tonumber(startingStacks[OPPONENT]) or 0)),
		},
		events = {
			{ round = "PREFLOP", seat = game.button, action = "SMALL_BLIND", amount = game.smallBlind },
			{ round = "PREFLOP", seat = OtherSeat(game.button), action = "BIG_BLIND", amount = game.bigBlind },
		},
		lastActionSequence = 0,
	}
	return game.currentHandHistory
end

local function AddPokerDealHistory(game, round)
	local history = game.currentHandHistory
	if not history or history.archived then return end
	table.insert(history.events, {
		sequence = game.sequence,
		round = round,
		action = "DEAL",
		board = CopyArray(game.board),
	})
end

local function ArchivePokerHandHistory(game)
	local history = game.currentHandHistory
	if not history or history.archived then return false end
	history.archived = true
	history.completedAt = GetServerTime and GetServerTime() or 0
	history.practice = game.practice and true or nil
	history.board = CopyArray(game.board)
	history.winnerSeat = game.winnerSeat
	history.tie = game.tie
	history.resultPot = game.resultPot
	history.finishReason = game.finishReason
	history.showdownNames = game.showdownNames and {
		[CHALLENGER] = game.showdownNames[CHALLENGER],
		[OPPONENT] = game.showdownNames[OPPONENT],
	} or nil
	history.holeCards = {}
	local localSeat = GetPlayerSeat(game, GetLocalPlayerName())
	history.localSeat = localSeat
	if game.practice or game.finishReason == "SHOWDOWN" then
		history.holeCards[CHALLENGER] = CopyArray(game.holeCards[CHALLENGER])
		history.holeCards[OPPONENT] = CopyArray(game.holeCards[OPPONENT])
	elseif localSeat then
		history.holeCards[localSeat] = CopyArray(game.holeCards[localSeat])
	end
	game.handHistory = type(game.handHistory) == "table" and game.handHistory or {}
	table.insert(game.handHistory, 1, history)
	while #game.handHistory > 20 do table.remove(game.handHistory) end
	GuildPing:StorePokerHandHistory(history)
	return true
end

local function FinishHand(game, winnerSeat, finishReason, tie)
	local totalPot = TablePot(game)
	game.resultPot = totalPot
	game.tie = not not tie
	game.winnerSeat = winnerSeat
	game.finishReason = finishReason
	if tie then
		local half = math.floor(totalPot / 2)
		local oddChipSeat = OtherSeat(game.button)
		game.stacks[CHALLENGER] = game.stacks[CHALLENGER] + half
		game.stacks[OPPONENT] = game.stacks[OPPONENT] + half
		game.stacks[oddChipSeat] = game.stacks[oddChipSeat] + (totalPot - (half * 2))
	elseif winnerSeat then
		game.stacks[winnerSeat] = game.stacks[winnerSeat] + totalPot
	end
	game.pot = 0
	game.roundBets[CHALLENGER] = 0
	game.roundBets[OPPONENT] = 0
	game.currentBet = 0
	game.turn = nil
	game.handStatus = "COMPLETE"
	game.round = "SHOWDOWN"
	if game.stacks[CHALLENGER] <= 0 or game.stacks[OPPONENT] <= 0 then
		game.status = "FINISHED"
		game.matchWinnerSeat = game.stacks[CHALLENGER] > game.stacks[OPPONENT]
			and CHALLENGER or OPPONENT
	end
	ArchivePokerHandHistory(game)
end

local function ResolveShowdown(game)
	while #game.board < 5 do
		if #game.board == 0 or #game.board == 3 or #game.board == 4 then
			BurnCard(game)
		end
		local cardsToDraw = #game.board == 0 and 3 or 1
		for _ = 1, cardsToDraw do
			table.insert(game.board, DrawCard(game))
		end
	end
	MoveBetsToPot(game)
	local challengerCards = CopyArray(game.holeCards[CHALLENGER])
	local opponentCards = CopyArray(game.holeCards[OPPONENT])
	for _, card in ipairs(game.board) do
		table.insert(challengerCards, card)
		table.insert(opponentCards, card)
	end
	local challengerScore, challengerName = EvaluateSeven(challengerCards)
	local opponentScore, opponentName = EvaluateSeven(opponentCards)
	game.showdownNames = {
		[CHALLENGER] = challengerName,
		[OPPONENT] = opponentName,
	}
	local comparison = CompareScores(challengerScore, opponentScore)
	if comparison > 0 then
		FinishHand(game, CHALLENGER, "SHOWDOWN", false)
	elseif comparison < 0 then
		FinishHand(game, OPPONENT, "SHOWDOWN", false)
	else
		FinishHand(game, nil, "SHOWDOWN", true)
	end
end

local function StartBettingRound(game, round)
	game.round = round
	game.acted[CHALLENGER] = false
	game.acted[OPPONENT] = false
	game.currentBet = 0
	game.minRaise = game.bigBlind
	game.turn = OtherSeat(game.button)
	if game.stacks[game.turn] <= 0 then
		game.turn = OtherSeat(game.turn)
	end
	AddPokerDealHistory(game, round)
end

local function AdvanceStreet(game)
	MoveBetsToPot(game)
	if game.stacks[CHALLENGER] <= 0 or game.stacks[OPPONENT] <= 0 then
		ResolveShowdown(game)
		return
	end
	if game.round == "PREFLOP" then
		BurnCard(game)
		for _ = 1, 3 do table.insert(game.board, DrawCard(game)) end
		StartBettingRound(game, "FLOP")
	elseif game.round == "FLOP" then
		BurnCard(game)
		table.insert(game.board, DrawCard(game))
		StartBettingRound(game, "TURN")
	elseif game.round == "TURN" then
		BurnCard(game)
		table.insert(game.board, DrawCard(game))
		StartBettingRound(game, "RIVER")
	else
		ResolveShowdown(game)
	end
end

local function GetLegalActions(game, seat)
	local legal = {
		canFold = false,
		canCheck = false,
		canCall = false,
		canRaise = false,
		toCall = 0,
		callAmount = 0,
		minRaiseTo = 0,
		maxRaiseTo = 0,
	}
	if not game or game.status ~= "ACTIVE" or game.handStatus ~= "ACTIVE" or game.turn ~= seat then
		return legal
	end
	local ownBet = game.roundBets[seat] or 0
	local otherSeat = OtherSeat(seat)
	local stack = game.stacks[seat] or 0
	local otherStack = game.stacks[otherSeat] or 0
	legal.toCall = math.max(0, (game.currentBet or 0) - ownBet)
	legal.callAmount = math.min(legal.toCall, stack)
	legal.canCheck = legal.toCall == 0
	legal.canCall = legal.toCall > 0 and stack > 0
	legal.canFold = legal.toCall > 0
	legal.maxRaiseTo = ownBet + stack
	local minimumOpen = math.max(game.bigBlind or BIG_BLIND, game.minRaise or BIG_BLIND)
	legal.minRaiseTo = game.currentBet > 0
		and game.currentBet + (game.minRaise or game.bigBlind)
		or minimumOpen
	legal.canRaise = otherStack > 0
		and stack > legal.toCall
		and legal.maxRaiseTo > game.currentBet
		and (legal.maxRaiseTo >= legal.minRaiseTo or legal.maxRaiseTo == ownBet + stack)
	return legal
end

local function MaybeProgressHand(game, actingSeat)
	if game.handStatus ~= "ACTIVE" then
		return
	end
	local challengerStack = game.stacks[CHALLENGER] or 0
	local opponentStack = game.stacks[OPPONENT] or 0
	if challengerStack <= 0 or opponentStack <= 0 then
		local liveSeat = challengerStack > 0 and CHALLENGER or opponentStack > 0 and OPPONENT or nil
		if liveSeat then
			local toCall = math.max(0, game.currentBet - (game.roundBets[liveSeat] or 0))
			if toCall > 0 then
				game.turn = liveSeat
				return
			end
		end
		RefundUncalledChips(game)
		ResolveShowdown(game)
		return
	end
	if game.roundBets[CHALLENGER] == game.roundBets[OPPONENT]
		and game.acted[CHALLENGER] and game.acted[OPPONENT] then
		AdvanceStreet(game)
	else
		game.turn = OtherSeat(actingSeat)
	end
end

local function ApplyAction(game, seat, action, amount)
	action = tostring(action or ""):upper()
	local legal = GetLegalActions(game, seat)
	if game.turn ~= seat or game.handStatus ~= "ACTIVE" then
		return nil, "It is not that player's turn."
	end
	if action == "FOLD" then
		if not legal.canFold then
			return nil, "You can check; there is no bet to fold to."
		end
		game.sequence = game.sequence + 1
		game.foldedSeat = seat
		AddActionLog(game, seat, "FOLD", 0)
		FinishHand(game, OtherSeat(seat), "FOLD", false)
		return true
	elseif action == "CHECK" then
		if not legal.canCheck then
			return nil, "Call, raise, or fold to the current bet."
		end
		game.sequence = game.sequence + 1
		game.acted[seat] = true
		AddActionLog(game, seat, "CHECK", 0)
		MaybeProgressHand(game, seat)
		return true
	elseif action == "CALL" then
		if not legal.canCall then
			return nil, legal.canCheck and "There is no bet to call." or "You cannot call right now."
		end
		local committed = CommitChips(game, seat, legal.toCall)
		game.sequence = game.sequence + 1
		game.acted[seat] = true
		AddActionLog(game, seat, "CALL", committed)
		MaybeProgressHand(game, seat)
		return true
	elseif action == "RAISE" then
		amount = math.floor(tonumber(amount) or 0)
		if not legal.canRaise then
			return nil, "You cannot bet or raise right now."
		end
		if amount <= game.currentBet or amount > legal.maxRaiseTo then
			return nil, "That bet is outside the available chip range."
		end
		if amount < legal.minRaiseTo and amount ~= legal.maxRaiseTo then
			return nil, "The minimum raise is to " .. legal.minRaiseTo .. "."
		end
		local previousBet = game.currentBet
		local ownBet = game.roundBets[seat] or 0
		CommitChips(game, seat, amount - ownBet)
		game.currentBet = amount
		local raiseSize = amount - previousBet
		if raiseSize >= game.minRaise then
			game.minRaise = raiseSize
		end
		game.sequence = game.sequence + 1
		game.acted[seat] = true
		game.acted[OtherSeat(seat)] = false
		AddActionLog(game, seat, "RAISE", amount)
		MaybeProgressHand(game, seat)
		return true
	end
	return nil, "Choose fold, check, call, or raise."
end

local function BeginHand(game)
	if not game or game.status ~= "ACTIVE" then
		return nil, "That poker match has ended."
	end
	if game.handStatus ~= "WAITING" and game.handStatus ~= "COMPLETE" then
		return nil, "Finish the current hand first."
	end
	if game.stacks[CHALLENGER] <= 0 or game.stacks[OPPONENT] <= 0 then
		game.status = "FINISHED"
		game.matchWinnerSeat = game.stacks[CHALLENGER] > 0 and CHALLENGER or OPPONENT
		return nil, "The match has ended."
	end

	game.handNumber = game.handNumber + 1
	game.button = game.handNumber == 1 and CHALLENGER or OtherSeat(game.button)
	game.handStatus = "ACTIVE"
	game.round = "PREFLOP"
	game.board = {}
	game.deck = BuildDeck()
	game.holeCards = { [CHALLENGER] = {}, [OPPONENT] = {} }
	for _ = 1, 2 do
		table.insert(game.holeCards[game.button], DrawCard(game))
		table.insert(game.holeCards[OtherSeat(game.button)], DrawCard(game))
	end
	game.pot = 0
	game.roundBets = { [CHALLENGER] = 0, [OPPONENT] = 0 }
	game.acted = { [CHALLENGER] = false, [OPPONENT] = false }
	game.currentBet = 0
	game.minRaise = game.bigBlind
	game.foldedSeat = nil
	game.winnerSeat = nil
	game.matchWinnerSeat = nil
	game.tie = false
	game.finishReason = nil
	game.resultPot = nil
	game.showdownNames = nil
	game.lastActor = nil
	game.lastAction = nil
	game.lastAmount = 0
	StartPokerHandHistory(game, {
		[CHALLENGER] = game.stacks[CHALLENGER],
		[OPPONENT] = game.stacks[OPPONENT],
	})

	CommitChips(game, game.button, game.smallBlind)
	CommitChips(game, OtherSeat(game.button), game.bigBlind)
	game.currentBet = math.max(game.roundBets[CHALLENGER], game.roundBets[OPPONENT])
	game.turn = game.button
	game.sequence = game.sequence + 1
	if game.stacks[CHALLENGER] <= 0 or game.stacks[OPPONENT] <= 0 then
		MaybeProgressHand(game, OtherSeat(game.button))
	end
	return true
end

local function EncodeCards(cards)
	return cards and #cards > 0 and table.concat(cards, ",") or "-"
end

local function DecodeCards(value, maximum)
	if value == "-" then
		return {}
	end
	local cards = {}
	for card in tostring(value):gmatch("[^,]+") do
		if not IsValidCard(card) or #cards >= maximum then
			return nil
		end
		table.insert(cards, card)
	end
	return cards
end

local function SnapshotMessage(game)
	local revealOpponent = game.handStatus == "COMPLETE" and game.finishReason == "SHOWDOWN"
	local ownCards = game.holeCards[OPPONENT] or {}
	local opponentCards = revealOpponent and game.holeCards[CHALLENGER] or {}
	local winnerCode = game.tie and "T" or SeatCode(game.winnerSeat)
	local finishCode = game.finishReason == "FOLD" and "F"
		or game.finishReason == "SHOWDOWN" and "S"
		or game.finishReason == "RESIGN" and "R" or "N"
	local handCode = game.handStatus == "ACTIVE" and "A"
		or game.handStatus == "COMPLETE" and "C" or "W"
	return table.concat({
		"S", PROTOCOL_VERSION, game.id, tostring(game.sequence), tostring(game.handNumber),
		SeatCode(game.button), ROUND_CODES[game.round] or "P", SeatCode(game.turn),
		game.status == "FINISHED" and "F" or "A", handCode,
		tostring(game.stacks[CHALLENGER] or 0), tostring(game.stacks[OPPONENT] or 0),
		tostring(game.pot or 0), tostring(game.currentBet or 0), tostring(game.minRaise or game.bigBlind),
		tostring(game.roundBets[CHALLENGER] or 0), tostring(game.roundBets[OPPONENT] or 0),
		EncodeCards(game.board), EncodeCards(ownCards), EncodeCards(opponentCards),
		SeatCode(game.foldedSeat), winnerCode, tostring(game.resultPot or 0), finishCode,
		SeatCode(game.lastActor), ACTION_CODES[game.lastAction] or "N", tostring(game.lastAmount or 0),
		SeatCode(game.matchWinnerSeat),
	}, "|")
end

local function ApplySnapshot(game, message)
	local fields = SplitMessage(message)
	if #fields ~= 28 or fields[1] ~= "S" or fields[2] ~= PROTOCOL_VERSION or fields[3] ~= game.id then
		return nil
	end
	local sequence = tonumber(fields[4])
	local handNumber = tonumber(fields[5])
	local challengerStack = tonumber(fields[11])
	local opponentStack = tonumber(fields[12])
	local pot = tonumber(fields[13])
	local currentBet = tonumber(fields[14])
	local minRaise = tonumber(fields[15])
	local challengerBet = tonumber(fields[16])
	local opponentBet = tonumber(fields[17])
	local resultPot = tonumber(fields[23])
	local lastAmount = tonumber(fields[27])
	if not sequence or sequence < (game.sequence or 0) or not handNumber or handNumber < 0
		or not challengerStack or challengerStack < 0 or not opponentStack or opponentStack < 0
		or not pot or pot < 0 or not currentBet or currentBet < 0 or not minRaise or minRaise < 1
		or not challengerBet or challengerBet < 0 or not opponentBet or opponentBet < 0
		or not resultPot or resultPot < 0 or not lastAmount or lastAmount < 0 then
		return nil
	end
	if challengerStack + opponentStack + pot + challengerBet + opponentBet ~= game.startingStack * 2 then
		return nil
	end
	local board = DecodeCards(fields[18], 5)
	local ownCards = DecodeCards(fields[19], 2)
	local opponentCards = DecodeCards(fields[20], 2)
	if not board or not ownCards or not opponentCards
		or (#ownCards ~= 0 and #ownCards ~= 2) or (#opponentCards ~= 0 and #opponentCards ~= 2) then
		return nil
	end
	local seen = {}
	for _, collection in ipairs({ board, ownCards, opponentCards }) do
		for _, card in ipairs(collection) do
			if seen[card] then return nil end
			seen[card] = true
		end
	end
	local round = CODE_ROUNDS[fields[7]]
	local handStatus = fields[10] == "A" and "ACTIVE" or fields[10] == "C" and "COMPLETE"
		or fields[10] == "W" and "WAITING" or nil
	if not round or not handStatus or not (fields[9] == "A" or fields[9] == "F") then
		return nil
	end
	if handStatus == "ACTIVE" and #ownCards ~= 2 then
		return nil
	end

	local oldHandNumber = game.handNumber or 0
	local oldHandStatus = game.handStatus
	local oldBoardCount = #(game.board or {})
	game.sequence = sequence
	game.handNumber = handNumber
	game.button = CodeSeat(fields[6])
	game.round = round
	game.turn = CodeSeat(fields[8])
	game.status = fields[9] == "F" and "FINISHED" or "ACTIVE"
	game.handStatus = handStatus
	game.stacks[CHALLENGER] = challengerStack
	game.stacks[OPPONENT] = opponentStack
	game.pot = pot
	game.currentBet = currentBet
	game.minRaise = minRaise
	game.roundBets[CHALLENGER] = challengerBet
	game.roundBets[OPPONENT] = opponentBet
	game.board = board
	if handNumber ~= oldHandNumber then
		game.holeCards = { [CHALLENGER] = {}, [OPPONENT] = {} }
	end
	game.holeCards[OPPONENT] = ownCards
	game.holeCards[CHALLENGER] = opponentCards
	game.foldedSeat = CodeSeat(fields[21])
	game.tie = fields[22] == "T"
	game.winnerSeat = CodeSeat(fields[22])
	game.resultPot = resultPot > 0 and resultPot or nil
	game.finishReason = fields[24] == "F" and "FOLD" or fields[24] == "S" and "SHOWDOWN"
		or fields[24] == "R" and "RESIGN" or nil
	game.lastActor = CodeSeat(fields[25])
	game.lastAction = CODE_ACTIONS[fields[26]]
	game.lastAmount = lastAmount
	game.matchWinnerSeat = CodeSeat(fields[28])
	game.pendingAction = false
	if game.finishReason == "SHOWDOWN" and #opponentCards == 2 and #ownCards == 2 and #board == 5 then
		local challengerCards = CopyArray(opponentCards)
		local opponentFullCards = CopyArray(ownCards)
		for _, card in ipairs(board) do
			table.insert(challengerCards, card)
			table.insert(opponentFullCards, card)
		end
		local _, challengerName = EvaluateSeven(challengerCards)
		local _, opponentName = EvaluateSeven(opponentFullCards)
		game.showdownNames = {
			[CHALLENGER] = challengerName,
			[OPPONENT] = opponentName,
		}
	else
		game.showdownNames = nil
	end

	local history = game.currentHandHistory
	if handNumber ~= oldHandNumber or not history or history.handNumber ~= handNumber then
		history = StartPokerHandHistory(game, {
			[CHALLENGER] = challengerStack + challengerBet,
			[OPPONENT] = opponentStack + opponentBet,
		})
	end
	if game.lastAction and game.sequence > (history.lastActionSequence or 0) then
		table.insert(history.events, {
			sequence = game.sequence,
			round = game.round,
			seat = game.lastActor,
			action = game.lastAction,
			amount = game.lastAmount,
		})
		history.lastActionSequence = game.sequence
	end
	if #game.board > oldBoardCount then
		AddPokerDealHistory(game, game.round)
	end
	if game.handStatus == "COMPLETE" and oldHandStatus ~= "COMPLETE" then
		ArchivePokerHandHistory(game)
	end
	return true
end

function GuildPing:GetLocalPokerPlayerName()
	return GetLocalPlayerName()
end

function GuildPing:GetPokerSeatPlayer(game, seat)
	return GetSeatPlayer(game or self.activePokerGame, seat)
end

function GuildPing:GetLocalPokerSeat(game)
	game = game or self.activePokerGame
	return GetPlayerSeat(game, GetLocalPlayerName())
end

function GuildPing:GetPokerControllableSeat(game)
	game = game or self.activePokerGame
	if game and game.practice then
		return game.turn
	end
	return self:GetLocalPokerSeat(game)
end

function GuildPing:GetPokerOpponent(game)
	game = game or self.activePokerGame
	if not game then return nil end
	if game.practice then return "Practice table" end
	local localSeat = self:GetLocalPokerSeat(game)
	return localSeat and GetSeatPlayer(game, OtherSeat(localSeat)) or nil
end

function GuildPing:IsPokerAuthority(game)
	game = game or self.activePokerGame
	return game and (game.practice or NamesMatch(game.challenger, GetLocalPlayerName())) or false
end

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

function GuildPing:GetPokerTablePot(game)
	return TablePot(game or self.activePokerGame)
end

function GuildPing:GetPokerLegalActions(game, seat)
	game = game or self.activePokerGame
	seat = seat or self:GetPokerControllableSeat(game)
	return GetLegalActions(game, seat)
end

function GuildPing:GetPokerPresetRaiseTo(game, seat, preset)
	game = game or self.activePokerGame
	seat = seat or self:GetPokerControllableSeat(game)
	local legal = GetLegalActions(game, seat)
	if not legal.canRaise then return nil end
	if preset == "ALLIN" then return legal.maxRaiseTo end
	if preset == "MIN" then return math.min(legal.maxRaiseTo, legal.minRaiseTo) end
	local ownBet = game.roundBets[seat] or 0
	local potAfterCall = TablePot(game) + legal.toCall
	local multiplier = preset == "HALF" and 0.5 or 1
	local target = ownBet + legal.toCall + math.max(1, math.floor(potAfterCall * multiplier))
	return math.max(math.min(legal.minRaiseTo, legal.maxRaiseTo), math.min(legal.maxRaiseTo, target))
end

function GuildPing:EvaluatePokerHand(cards)
	return EvaluateSeven(cards)
end

function GuildPing:RefreshPokerUI()
	if self.activePokerGame then
		local game = self.activePokerGame
		local localSeat = self:GetLocalPokerSeat(game)
		self:RecordPokerHandResult(game)
		if game.status == "FINISHED" then
			local result = game.matchWinnerSeat == nil and "DRAW"
				or game.matchWinnerSeat == localSeat and "WIN" or "LOSS"
			self:RecordGameResult("poker", game, result, self:GetPokerOpponent(game), {
				reason = game.finishReason or "match complete",
				resigned = game.finishReason == "RESIGN" and game.matchWinnerSeat ~= localSeat,
			})
		end
		self:PersistGameState("poker", game)
		if self.MaybeBroadcastSpectatableGames and not game.practice and game.status == "ACTIVE"
			and self:IsPokerAuthority(game) then self:MaybeBroadcastSpectatableGames() end
		if self.PublishSpectatorState then self:PublishSpectatorState("P", game) end
		if localSeat and game.handStatus == "ACTIVE" and game.turn == localSeat and not game.pendingAction then
			self:NotifyGameTurn(
				"poker",
				game,
				tostring(game.sequence or 0) .. ":" .. tostring(game.turn),
				"Your turn at the Guild Ping Hold 'Em table",
				self.pokerTableFrame and self.pokerTableFrame:IsShown()
			)
		end
	end
	if self.RefreshPokerTable and self.pokerTableFrame and self.pokerTableFrame:IsShown() then
		self:RefreshPokerTable()
	end
	if self.RefreshPokerLobby and self.pokerLobby and self.pokerLobby:IsShown() then
		self:RefreshPokerLobby()
	end
	if self.RefreshGamesIndicators then
		self:RefreshGamesIndicators()
	end
end

function GuildPing:SendPokerAddonMessage(message, target)
	if not self.pokerInitialized then
		return nil, "Poker messaging is not available."
	end
	if not IsInGuild or not IsInGuild() then
		return nil, "You must be in a guild to play Guild Ping Hold 'Em."
	end
	if not target or target == "" then
		return nil, "The poker opponent could not be found."
	end
	if #message > 255 then
		return nil, "The poker update was too large to send."
	end
	local succeeded, result = pcall(C_ChatInfo.SendAddonMessage, MESSAGE_PREFIX, message, "WHISPER", target)
	if not succeeded or result == false then
		return nil, "WoW could not send the poker message."
	end
	return true
end

function GuildPing:SendPokerSnapshot(game, target)
	game = game or self.activePokerGame
	if not game or game.practice then return true end
	return self:SendPokerAddonMessage(SnapshotMessage(game), target or game.opponent)
end

function GuildPing:ResolvePokerGuildMember(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
			if NormalizePlayerName(memberName) == 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:CleanupPokerInvites()
	local currentTime = GetTime and GetTime() or 0
	local invites = self.pokerInvites 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.outgoingPokerChallenge
		and currentTime - (self.outgoingPokerChallenge.sentAt or 0) > CHALLENGE_TIMEOUT then
		self.outgoingPokerChallenge = nil
	end
end

function GuildPing:GetPokerInvites()
	self:CleanupPokerInvites()
	self.pokerInvites = self.pokerInvites or {}
	return self.pokerInvites
end

function GuildPing:GetPokerInviteCount()
	return #self:GetPokerInvites()
end

function GuildPing:StartPokerPractice(startingStack)
	local localName = GetLocalPlayerName()
	local _, profileKey = self:GetProfile()
	local game = CreateGame(CreateGameID(), localName, localName, startingStack, profileKey)
	game.practice = true
	BeginHand(game)
	self.activePokerGame = game
	self.outgoingPokerChallenge = nil
	self:RefreshPokerUI()
	return game
end

function GuildPing:ChallengePokerPlayer(targetInput, startingStack)
	self:CleanupPokerInvites()
	if self.activePokerGame and self.activePokerGame.status == "ACTIVE" then
		return nil, "Finish or leave your current poker match first."
	end
	if self.outgoingPokerChallenge then
		return nil, "You already have a poker challenge waiting for a response."
	end
	startingStack = NormalizeStartingStack(startingStack)
	local requestedName = Trim(targetInput):gsub("^@", "")
	if NamesMatch(requestedName, GetLocalPlayerName()) then
		local game = self:StartPokerPractice(startingStack)
		return { practice = true, game = game, target = GetLocalPlayerName(), startingStack = startingStack }
	end
	local target, errorMessage = self:ResolvePokerGuildMember(targetInput)
	if not target then return nil, errorMessage end
	if NamesMatch(target, GetLocalPlayerName()) then
		local game = self:StartPokerPractice(startingStack)
		return { practice = true, game = game, target = GetLocalPlayerName(), startingStack = startingStack }
	end
	local gameID = CreateGameID()
	local sent, sendError = self:SendPokerAddonMessage(table.concat({
		"I", PROTOCOL_VERSION, gameID, tostring(startingStack), tostring(SMALL_BLIND), tostring(BIG_BLIND),
	}, "|"), target)
	if not sent then return nil, sendError end
	self.outgoingPokerChallenge = {
		id = gameID,
		target = target,
		startingStack = startingStack,
		sentAt = GetTime and GetTime() or 0,
	}
	self:RefreshPokerUI()
	return self.outgoingPokerChallenge
end

function GuildPing:PlayPokerAgain()
	local game = self.activePokerGame
	if not game or game.status ~= "FINISHED" then
		return nil, "Finish the current Hold 'Em match before starting a rematch."
	end
	if not self:IsPokerGameForCurrentGuild(game) then
		return nil, "This Hold 'Em match belongs to a different guild."
	end
	local target = game.practice and self:GetLocalPokerPlayerName() or self:GetPokerOpponent(game)
	if not target then return nil, "The previous Hold 'Em opponent could not be found." end
	return self:ChallengePokerPlayer(target, game.startingStack)
end

function GuildPing:ResumePokerGame(delay)
	local game = self.activePokerGame
	if not game then
		return nil, "There is no saved Hold 'Em match to resume."
	end
	if not self:IsPokerGameForCurrentGuild(game) then
		return nil, "This Hold 'Em match belongs to a different guild."
	end
	if game.status ~= "ACTIVE" or game.practice then
		game.persistenceState = nil
		self:PersistGameState("poker", game)
		self:RefreshPokerUI()
		return true
	end
	local opponent = self:GetPokerOpponent(game)
	if not opponent then
		return nil, "The previous Hold 'Em opponent could not be found."
	end
	self:BeginGameReconnect("poker", game, function()
		local sent, errorMessage = self:RequestPokerSync(game)
		if not sent and game.persistenceState == "RECONNECTING" then
			game.persistenceState = "STALE"
			self:PersistGameState("poker", game)
			if errorMessage then self:Print(errorMessage) end
			self:RefreshPokerUI()
		end
	end, delay)
	self:RefreshPokerUI()
	return true
end

function GuildPing:AbandonPokerGame()
	local game = self.activePokerGame
	if not game then
		return nil, "There is no Hold 'Em match to abandon."
	end
	if game.status == "ACTIVE" and not game.practice then
		self:ResignPokerGame()
	end
	self.activePokerGame = nil
	self:ClearSavedGameState("poker")
	self:RefreshPokerUI()
	return true
end

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

function GuildPing:AcceptPokerChallenge(inviteID)
	if self.activePokerGame and self.activePokerGame.status == "ACTIVE" then
		return nil, "Finish or leave your current poker match first."
	end
	local invite, index = self:GetPokerInvite(inviteID)
	if not invite then return nil, "That poker challenge is no longer available." end
	local sent, errorMessage = self:SendPokerAddonMessage(
		table.concat({ "A", PROTOCOL_VERSION, invite.id }, "|"), invite.sender
	)
	if not sent then return nil, errorMessage end
	local _, profileKey = self:GetProfile()
	self.activePokerGame = CreateGame(invite.id, invite.sender, GetLocalPlayerName(), invite.startingStack, profileKey)
	table.remove(self.pokerInvites, index)
	self.outgoingPokerChallenge = nil
	self:RefreshPokerUI()
	return self.activePokerGame
end

function GuildPing:DeclinePokerChallenge(inviteID)
	local invite, index = self:GetPokerInvite(inviteID)
	if not invite then return false end
	self:SendPokerAddonMessage(table.concat({ "D", PROTOCOL_VERSION, invite.id }, "|"), invite.sender)
	table.remove(self.pokerInvites, index)
	self:RefreshPokerUI()
	return true
end

function GuildPing:PlayPokerAction(action, amount)
	local game = self.activePokerGame
	if not game or game.status ~= "ACTIVE" or game.handStatus ~= "ACTIVE" then
		return nil, "There is no active poker hand."
	end
	if not self:IsPokerGameForCurrentGuild(game) then
		return nil, "This poker match belongs to a different guild."
	end
	if self:IsGameWaitingForResume(game) then
		return nil, "Resume this Hold 'Em match before acting."
	end
	local seat = self:GetPokerControllableSeat(game)
	if not seat or seat ~= game.turn then return nil, "Wait for your opponent's action." end
	action = tostring(action or ""):upper()
	local actionCode = ACTION_CODES[action]
	if not actionCode then return nil, "Choose a valid poker action." end

	if game.practice or self:IsPokerAuthority(game) then
		local previousBoardCount = #game.board
		local applied, errorMessage = ApplyAction(game, seat, action, amount)
		if not applied then return nil, errorMessage end
		if not game.practice then
			local sent, sendError = self:SendPokerSnapshot(game)
			if not sent then return nil, sendError end
		end
		if self.BeginPokerDealAnimation and #game.board > previousBoardCount then
			self:BeginPokerDealAnimation(previousBoardCount + 1, #game.board)
		end
		self:RefreshPokerUI()
		return true
	end

	if game.pendingAction then return nil, "Waiting for the table host to confirm your last action." end
	local sent, errorMessage = self:SendPokerAddonMessage(table.concat({
		"P", PROTOCOL_VERSION, game.id, tostring(game.sequence), actionCode, tostring(math.floor(tonumber(amount) or 0)),
	}, "|"), game.challenger)
	if not sent then return nil, errorMessage end
	game.pendingAction = true
	self:RefreshPokerUI()
	return true
end

function GuildPing:StartNextPokerHand()
	local game = self.activePokerGame
	if not game or game.status ~= "ACTIVE" or game.handStatus ~= "COMPLETE" then
		return nil, "The next hand is not ready yet."
	end
	if self:IsGameWaitingForResume(game) then
		return nil, "Resume this Hold 'Em match before dealing the next hand."
	end
	if game.practice or self:IsPokerAuthority(game) then
		local started, errorMessage = BeginHand(game)
		if not started then return nil, errorMessage end
		if not game.practice then
			local sent, sendError = self:SendPokerSnapshot(game)
			if not sent then return nil, sendError end
		end
		self:RefreshPokerUI()
		return true
	end
	if game.pendingAction then return nil, "Waiting for the table host." end
	local sent, errorMessage = self:SendPokerAddonMessage(table.concat({
		"N", PROTOCOL_VERSION, game.id, tostring(game.sequence),
	}, "|"), game.challenger)
	if not sent then return nil, errorMessage end
	game.pendingAction = true
	self:RefreshPokerUI()
	return true
end

local function FinishPokerMatchByResignation(game, loserSeat)
	game.sequence = game.sequence + 1
	game.status = "FINISHED"
	game.handStatus = "COMPLETE"
	game.turn = nil
	game.winnerSeat = OtherSeat(loserSeat)
	game.matchWinnerSeat = OtherSeat(loserSeat)
	game.finishReason = "RESIGN"
	game.tie = false
	game.resultPot = TablePot(game)
	game.lastActor = loserSeat
	game.lastAction = nil
	game.lastAmount = 0
end

function GuildPing:ResignPokerGame()
	local game = self.activePokerGame
	if not game or game.status ~= "ACTIVE" then
		return nil, "There is no active poker match to leave."
	end
	if game.practice then
		game.sequence = game.sequence + 1
		game.status = "FINISHED"
		game.handStatus = "COMPLETE"
		game.turn = nil
		game.finishReason = "PRACTICE"
		self:RefreshPokerUI()
		return true
	end
	local localSeat = self:GetLocalPokerSeat(game)
	if self:IsPokerAuthority(game) then
		FinishPokerMatchByResignation(game, localSeat)
		local sent, errorMessage = self:SendPokerSnapshot(game)
		if not sent then return nil, errorMessage end
		self:RefreshPokerUI()
		return true
	end
	local sent, errorMessage = self:SendPokerAddonMessage(
		table.concat({ "R", PROTOCOL_VERSION, game.id, tostring(game.sequence) }, "|"), game.challenger
	)
	if not sent then return nil, errorMessage end
	game.pendingAction = true
	self:RefreshPokerUI()
	return true
end

function GuildPing:RequestPokerSync(game)
	game = game or self.activePokerGame
	if not game then return nil, "There is no Hold 'Em match to synchronize." end
	if game.practice then return true end
	if self:IsPokerAuthority(game) then
		return self:SendPokerSnapshot(game, game.opponent)
	end
	return self:SendPokerAddonMessage(table.concat({ "Q", PROTOCOL_VERSION, game.id }, "|"), game.challenger)
end

function GuildPing:HandlePokerMessage(prefix, message, channel, sender)
	if not self.pokerInitialized or prefix ~= MESSAGE_PREFIX or channel ~= "WHISPER"
		or type(message) ~= "string" or type(sender) ~= "string"
		or NamesMatch(sender, GetLocalPlayerName()) then
		return
	end
	local fields = SplitMessage(message)
	if fields[2] ~= PROTOCOL_VERSION then return end
	local messageType = fields[1]
	local localName = GetLocalPlayerName()
	self:CleanupPokerInvites()

	if messageType == "I" then
		local gameID = fields[3]
		local startingStack = tonumber(fields[4])
		local smallBlind = tonumber(fields[5])
		local bigBlind = tonumber(fields[6])
		if #fields ~= 6 or not gameID or not gameID:match("^[%w%-]+$")
			or not startingStack or startingStack < MIN_STARTING_STACK or startingStack > MAX_STARTING_STACK
			or smallBlind ~= SMALL_BLIND or bigBlind ~= BIG_BLIND then return end
		local invites = self:GetPokerInvites()
		local currentTime = GetTime and GetTime() or 0
		self.pokerInviteSenderCooldowns = self.pokerInviteSenderCooldowns or {}
		local senderKey = NormalizePlayerName(sender)
		local lastInviteAt = self.pokerInviteSenderCooldowns[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.pokerInviteSenderCooldowns[senderKey] = currentTime
		table.insert(invites, {
			id = gameID,
			sender = sender,
			startingStack = startingStack,
			receivedAt = currentTime,
		})
		self:Print(sender .. " challenged you to heads-up Hold 'Em. Open /gp games to respond.")
		self:RefreshPokerUI()
	elseif messageType == "A" then
		local gameID = fields[3]
		local outgoing = self.outgoingPokerChallenge
		if #fields ~= 3 or not outgoing or outgoing.id ~= gameID or not NamesMatch(outgoing.target, sender) then return end
		if self.activePokerGame and self.activePokerGame.status == "ACTIVE" then
			self.outgoingPokerChallenge = nil
			return
		end
		local _, profileKey = self:GetProfile()
		local game = CreateGame(gameID, localName, sender, outgoing.startingStack, profileKey)
		self.activePokerGame = game
		self.outgoingPokerChallenge = nil
		BeginHand(game)
		self:SendPokerSnapshot(game, sender)
		self:Print(sender .. " accepted your Hold 'Em challenge. The first hand has been dealt.")
		self:RefreshPokerUI()
	elseif messageType == "D" then
		local outgoing = self.outgoingPokerChallenge
		if #fields == 3 and outgoing and outgoing.id == fields[3] and NamesMatch(outgoing.target, sender) then
			self.outgoingPokerChallenge = nil
			self:Print(sender .. " declined your Hold 'Em challenge.")
			self:RefreshPokerUI()
		end
	elseif messageType == "P" then
		local game = self.activePokerGame
		local sequence = tonumber(fields[4])
		local action = CODE_ACTIONS[fields[5]]
		local amount = tonumber(fields[6])
		if #fields ~= 6 or not game or not self:IsPokerAuthority(game) or game.id ~= fields[3]
			or not NamesMatch(sender, game.opponent) or sequence ~= game.sequence or not action or not amount then return end
		self:CompleteGameReconnect("poker", game)
		local previousBoardCount = #game.board
		local senderSeat = GetPlayerSeat(game, sender)
		local applied = ApplyAction(game, senderSeat, action, amount)
		self:SendPokerSnapshot(game, sender)
		if applied and self.BeginPokerDealAnimation and #game.board > previousBoardCount then
			self:BeginPokerDealAnimation(previousBoardCount + 1, #game.board)
		end
		self:RefreshPokerUI()
	elseif messageType == "N" then
		local game = self.activePokerGame
		local sequence = tonumber(fields[4])
		if #fields ~= 4 or not game or not self:IsPokerAuthority(game) or game.id ~= fields[3]
			or not NamesMatch(sender, game.opponent) or sequence ~= game.sequence then return end
		self:CompleteGameReconnect("poker", game)
		if game.status == "ACTIVE" and game.handStatus == "COMPLETE" then BeginHand(game) end
		self:SendPokerSnapshot(game, sender)
		self:RefreshPokerUI()
	elseif messageType == "Q" then
		local game = self.activePokerGame
		if #fields == 3 and game and self:IsPokerAuthority(game) and game.id == fields[3]
			and NamesMatch(sender, game.opponent) then
			self:CompleteGameReconnect("poker", game)
			self:SendPokerSnapshot(game, sender)
		end
	elseif messageType == "R" then
		local game = self.activePokerGame
		local sequence = tonumber(fields[4])
		if #fields ~= 4 or not game or not self:IsPokerAuthority(game) or game.id ~= fields[3]
			or not NamesMatch(sender, game.opponent) or sequence ~= game.sequence or game.status ~= "ACTIVE" then return end
		self:CompleteGameReconnect("poker", game)
		FinishPokerMatchByResignation(game, OPPONENT)
		self:SendPokerSnapshot(game, sender)
		self:Print(sender .. " left your Hold 'Em match.")
		self:RefreshPokerUI()
	elseif messageType == "S" then
		local game = self.activePokerGame
		if not game or self:IsPokerAuthority(game) or game.id ~= fields[3]
			or not NamesMatch(sender, game.challenger) then return end
		local previousBoardCount = #game.board
		if ApplySnapshot(game, message) then
			local reconnected = self:CompleteGameReconnect("poker", game)
			if reconnected then
				self:SendPokerAddonMessage(table.concat({ "Q", PROTOCOL_VERSION, game.id }, "|"), game.challenger)
			end
			if self.BeginPokerDealAnimation and #game.board > previousBoardCount then
				self:BeginPokerDealAnimation(previousBoardCount + 1, #game.board)
			end
			self:RefreshPokerUI()
		end
	end
end

function GuildPing:InitializePoker()
	if self.pokerInitialized 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.pokerInitialized = true
	self.pokerInvites = self.pokerInvites or {}
	local restoredGame, restoreReason = self:RestoreSavedGameState("poker")
	local _, profileKey = self:GetProfile()
	if restoredGame and IsValidSavedPokerGame(restoredGame, profileKey) then
		self.activePokerGame = restoredGame
		restoredGame.spectators = {}
		restoredGame.lastSpectatorStateToken = nil
		if restoredGame.status == "ACTIVE" then
			if restoredGame.practice then
				restoredGame.persistenceState = "RESTORED"
				self:PersistGameState("poker", restoredGame)
			else
				self:ResumePokerGame(1.5)
			end
		end
	elseif restoredGame then
		self:ClearSavedGameState("poker")
	elseif restoreReason == "EXPIRED" then
		self:Print("An abandoned saved Hold 'Em match expired and was removed.")
	end
	if C_GuildInfo and C_GuildInfo.GuildRoster then C_GuildInfo.GuildRoster() end
end

GuildPing.POKER_CHALLENGER = CHALLENGER
GuildPing.POKER_OPPONENT = OPPONENT
GuildPing.POKER_DEFAULT_STACK = DEFAULT_STARTING_STACK
GuildPing.POKER_MINIMUM_STACK = MIN_STARTING_STACK
GuildPing.POKER_MAXIMUM_STACK = MAX_STARTING_STACK
GuildPing.POKER_SMALL_BLIND = SMALL_BLIND
GuildPing.POKER_BIG_BLIND = BIG_BLIND
