GuildHelper = GuildHelper or {}

local addon = GuildHelper

addon.RaidBingoService = addon.RaidBingoService or {}

local PREFIX = "GH3BINGO"
local STATE_IDLE = "idle"
local STATE_ACTIVE = "active"
local MAX_ROLLSYNC_PAYLOAD = 180
local PAYOUT_WEIGHTS = {
    [3] = 0.10,
    [4] = 0.30,
    [5] = 0.60,
}

local function isInInstanceGroup()
    return LE_PARTY_CATEGORY_INSTANCE and IsInGroup and IsInGroup(LE_PARTY_CATEGORY_INSTANCE) and true or false
end

local function sendAddonMessage(prefix, payload, channel, target)
    if C_ChatInfo and C_ChatInfo.SendAddonMessage then
        local ok, result = pcall(C_ChatInfo.SendAddonMessage, prefix, payload, channel, target)

        if ok then
            return result
        end

        local parameterSets = {
            { prefix = prefix, message = payload, chatType = channel, target = target },
            { prefix = prefix, text = payload, chatType = channel, target = target },
            { prefix = prefix, message = payload, distribution = channel, target = target },
            { prefix = prefix, text = payload, distribution = channel, target = target },
        }

        for _, params in ipairs(parameterSets) do
            local tableOk, tableResult = pcall(C_ChatInfo.SendAddonMessage, params)

            if tableOk then
                return tableResult
            end
        end

        return nil
    end

    if SendAddonMessage then
        return SendAddonMessage(prefix, payload, channel, target)
    end

    return nil
end

local function copyArray(values)
    local copy = {}

    for index, value in ipairs(values or {}) do
        copy[index] = value
    end

    return copy
end

local function sortNumeric(values)
    table.sort(values, function(left, right)
        return tonumber(left) < tonumber(right)
    end)
end

local function normalizeName(name)
    if not name or name == "" then
        return addon.Store:GetPlayerName()
    end

    local normalized = tostring(name or ""):match("^%s*(.-)%s*$") or ""

    if string.find(normalized, "-", 1, true) then
        local player, realm = string.match(normalized, "^([^-]+)%-(.+)$")

        if player and realm then
            realm = tostring(realm):match("^([^-]+)") or realm
            return player .. "-" .. realm
        end

        return normalized
    end

    local realmName = GetRealmName() or "Unknown"
    return normalized .. "-" .. realmName
end

local function shortPlayerName(name)
    return tostring(name or "Unknown"):match("([^%-]+)") or tostring(name or "Unknown")
end

local function parseMessage(message)
    local command, payload = string.match(message or "", "^([^:]+):?(.*)$")
    return command, payload
end

local function decodeJson(payload)
    if not payload or payload == "" then
        return nil
    end

    local ok, result = pcall(function()
        return addon.Json:Decode(payload)
    end)

    if ok then
        return result
    end

    return nil
end

local function encodeJson(payload)
    local ok, result = pcall(function()
        return addon.Json:Encode(payload)
    end)

    if ok then
        return result
    end

    return "{}"
end

local function normalizePrizePoolText(value)
    return tostring(value or ""):match("^%s*(.-)%s*$") or ""
end

local function stylePlainButton(button)
    button:SetNormalTexture("Interface\\Buttons\\WHITE8X8")
    button:SetPushedTexture("Interface\\Buttons\\WHITE8X8")
    button:SetHighlightTexture("Interface\\Buttons\\WHITE8X8", "ADD")
    button:SetDisabledTexture("Interface\\Buttons\\WHITE8X8")

    if button:GetNormalTexture() then
        button:GetNormalTexture():SetAllPoints(button)
        button:GetNormalTexture():SetVertexColor(0.11, 0.13, 0.18, 0.96)
    end

    if button:GetPushedTexture() then
        button:GetPushedTexture():SetAllPoints(button)
        button:GetPushedTexture():SetVertexColor(0.06, 0.07, 0.1, 0.98)
    end

    if button:GetHighlightTexture() then
        button:GetHighlightTexture():SetAllPoints(button)
        button:GetHighlightTexture():SetVertexColor(0.94, 0.76, 0.24, 0.08)
    end

    if button:GetDisabledTexture() then
        button:GetDisabledTexture():SetAllPoints(button)
        button:GetDisabledTexture():SetVertexColor(0.05, 0.06, 0.08, 0.72)
    end

    if not button._gh3Label then
        local label = button:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        label:SetPoint("CENTER", button, "CENTER", 0, 0)
        label:SetFont("Fonts\\MORPHEUS.ttf", 14, "OUTLINE")
        label:SetTextColor(0.96, 0.84, 0.28, 1)
        label:SetShadowOffset(1, -1)
        button._gh3Label = label
    end

    button.GetFontString = function(self)
        return self._gh3Label
    end

    button.SetText = function(self, value)
        if self._gh3Label then
            self._gh3Label:SetText(value)
        end
    end
end

local function getCommChannel()
    if isInInstanceGroup() then
        return "INSTANCE_CHAT", nil
    end

    if IsInRaid() then
        return "RAID", nil
    end

    if IsInGroup() then
        return "PARTY", nil
    end

    return "WHISPER", addon.Store:GetPlayerName()
end

local function createProcessedMap(rolledNumbers)
    local processed = {}

    for _, number in ipairs(rolledNumbers or {}) do
        processed[tonumber(number)] = true
    end

    return processed
end

local function countSelected(selectedIndices)
    local count = 0

    for _ in pairs(selectedIndices or {}) do
        count = count + 1
    end

    return count
end

local function selectedMapFromArray(selectedIndices)
    local selected = {}

    for key, value in pairs(selectedIndices or {}) do
        local index = nil

        if type(key) == "number" and value == true then
            index = key
        else
            index = tonumber(value)
        end

        if index then
            selected[index] = true
        end
    end

    return selected
end

local function selectedArrayFromMap(selectedIndices)
    local selected = {}

    for index, enabled in pairs(selectedIndices or {}) do
        if enabled then
            selected[#selected + 1] = tonumber(index)
        end
    end

    table.sort(selected)
    return selected
end

local function flattenSelectedNumbers(card)
    local selectedNumbers = {}

    for _, index in ipairs(selectedArrayFromMap(card.selectedIndices)) do
        local value = tonumber(card.numbers[index])

        if value then
            selectedNumbers[#selectedNumbers + 1] = value
        end
    end

    sortNumeric(selectedNumbers)
    return selectedNumbers
end

local function mergeLockedCardSelection(session, storedCard, playerName)
    if type(session) ~= "table" or session.status ~= STATE_ACTIVE or not session.id then
        return false
    end

    if type(storedCard) ~= "table" or not storedCard.locked or storedCard.sessionId ~= session.id then
        return false
    end

    local normalizedPlayer = normalizeName(playerName)

    if normalizedPlayer == "" then
        return false
    end

    local selectedIndices = selectedMapFromArray(storedCard.selectedIndices)
    local numbers = {}
    local rolledSet = createProcessedMap(session.rolledNumbers)
    local matched = 0

    for _, index in ipairs(selectedArrayFromMap(selectedIndices)) do
        local number = tonumber(storedCard.numbers and storedCard.numbers[index])

        if number then
            numbers[#numbers + 1] = number

            if rolledSet[number] then
                matched = matched + 1
            end
        end
    end

    if #numbers ~= 5 then
        return false
    end

    sortNumeric(numbers)

    local current = session.memberSelections and session.memberSelections[normalizedPlayer] or nil
    local currentNumbers = current and copyArray(current.numbers) or {}
    sortNumeric(currentNumbers)

    session.memberSelections = session.memberSelections or {}
    session.memberSelections[normalizedPlayer] = {
        matched = matched,
        numbers = numbers,
        receivedAt = tonumber(storedCard.updatedAt) or time(),
        sender = normalizedPlayer,
    }

    if not current then
        return true
    end

    return tonumber(current.matched) ~= matched or encodeJson(currentNumbers) ~= encodeJson(numbers)
end

local function mergeRolledHistory(currentNumbers, incomingNumbers)
    local current = copyArray(currentNumbers)
    local incoming = copyArray(incomingNumbers)

    if #incoming > #current then
        current, incoming = incoming, current
    end

    local seen = createProcessedMap(current)

    for _, number in ipairs(incoming) do
        local normalizedNumber = tonumber(number)

        if normalizedNumber and not seen[normalizedNumber] then
            current[#current + 1] = normalizedNumber
            seen[normalizedNumber] = true
        end
    end

    return current
end

local function encodeRollChunk(sessionId, partIndex, totalParts, body)
    return string.format("%s|%d|%d|%s", tostring(sessionId or ""), tonumber(partIndex) or 1, tonumber(totalParts) or 1, tostring(body or ""))
end

local function decodeRollChunk(payloadText)
    local sessionId, partIndex, totalParts, body = string.match(payloadText or "", "^([^|]+)|(%d+)|(%d+)|?(.*)$")

    if not sessionId or not partIndex or not totalParts then
        return nil
    end

    return {
        body = body or "",
        partIndex = tonumber(partIndex) or 1,
        sessionId = sessionId,
        totalParts = tonumber(totalParts) or 1,
    }
end

local function decodeRollNumbers(csv)
    local numbers = {}

    for token in string.gmatch(tostring(csv or ""), "[^,]+") do
        local number = tonumber(token)

        if number then
            numbers[#numbers + 1] = number
        end
    end

    return numbers
end

local function buildRollChunkBodies(sessionId, rolledNumbers)
    local chunks = {}
    local numbers = copyArray(rolledNumbers)
    local reserve = string.len(tostring(sessionId or "")) + 12
    local maxBodyLength = math.max(24, MAX_ROLLSYNC_PAYLOAD - reserve)
    local current = ""

    for _, rawNumber in ipairs(numbers) do
        local token = tostring(tonumber(rawNumber) or rawNumber)
        local candidate = current == "" and token or (current .. "," .. token)

        if current ~= "" and string.len(candidate) > maxBodyLength then
            chunks[#chunks + 1] = current
            current = token
        else
            current = candidate
        end
    end

    if current ~= "" or #chunks == 0 then
        chunks[#chunks + 1] = current
    end

    return chunks
end

local function normalizeSelectionRecord(record)
    if type(record) ~= "table" then
        return {
            matched = 0,
            numbers = {},
            receivedAt = 0,
            sender = nil,
        }
    end

    record.numbers = copyArray(record.numbers)
    record.matched = tonumber(record.matched) or 0
    record.receivedAt = tonumber(record.receivedAt) or 0
    record.sender = record.sender and normalizeName(record.sender) or nil
    sortNumeric(record.numbers)
    return record
end

local function mergeSelectionRecord(session, sender, record)
    if type(session) ~= "table" or not session.id then
        return false
    end

    local normalizedSender = normalizeName(sender or record and record.sender)

    if normalizedSender == "" then
        return false
    end

    local normalizedRecord = normalizeSelectionRecord(record)
    normalizedRecord.sender = normalizedSender
    session.memberSelections = session.memberSelections or {}
    session.memberSelections[normalizedSender] = normalizedRecord
    return true
end

function addon.RaidBingoService:GetPrefix()
    return PREFIX
end

function addon.RaidBingoService:GetState()
    local profile = addon.Store:GetProfile()
    profile.raidBingo = profile.raidBingo or {}
    profile.raidBingo.card = profile.raidBingo.card or {}
    profile.raidBingo.pendingRollChunks = profile.raidBingo.pendingRollChunks or {}
    profile.raidBingo.session = profile.raidBingo.session or {}
    profile.raidBingo.pendingSelections = profile.raidBingo.pendingSelections or {}
    profile.raidBingo.ui = profile.raidBingo.ui or {}
    profile.raidBingo.exportWindow = profile.raidBingo.exportWindow or { width = 420, height = 320 }
    return profile.raidBingo
end

function addon.RaidBingoService:QueuePendingSelection(sessionId, sender, record)
    if not sessionId then
        return false
    end

    local state = self:GetState()
    local normalizedSender = normalizeName(sender or record and record.sender)

    if normalizedSender == "" then
        return false
    end

    state.pendingSelections[sessionId] = state.pendingSelections[sessionId] or {}
    state.pendingSelections[sessionId][normalizedSender] = normalizeSelectionRecord(record)
    state.pendingSelections[sessionId][normalizedSender].sender = normalizedSender
    return true
end

function addon.RaidBingoService:MergePendingSelections(session)
    if type(session) ~= "table" or not session.id then
        return false
    end

    local state = self:GetState()
    local pending = state.pendingSelections and state.pendingSelections[session.id]
    local changed = false

    if type(pending) ~= "table" then
        return false
    end

    for sender, record in pairs(pending) do
        changed = mergeSelectionRecord(session, sender, record) or changed
    end

    state.pendingSelections[session.id] = nil
    return changed
end

function addon.RaidBingoService:GetSession()
    local session = self:GetState().session
    session.id = session.id
    session.leader = session.leader and normalizeName(session.leader) or nil
    session.memberSelections = session.memberSelections or {}
    session.prizePool = normalizePrizePoolText(session.prizePool)
    session.rolledNumbers = copyArray(session.rolledNumbers)
    session.startedAt = tonumber(session.startedAt) or 0
    session.createdAt = tonumber(session.createdAt) or 0
    session.status = session.status or STATE_IDLE

    local normalizedSelections = {}

    for key, record in pairs(session.memberSelections) do
        local normalizedKey = normalizeName(record and record.sender or key)
        normalizedSelections[normalizedKey] = normalizeSelectionRecord(record)
        normalizedSelections[normalizedKey].sender = normalizedKey
    end

    session.memberSelections = normalizedSelections

    local storedCard = self:GetState().card or {}
    local localPlayer = self:GetPlayerName()

    if mergeLockedCardSelection(session, storedCard, localPlayer) then
        self:SaveSession(session)
    end

    return session
end

function addon.RaidBingoService:GetCard()
    local storedCard = self:GetState().card
    local card = {
        locked = storedCard.locked and true or false,
        numbers = copyArray(storedCard.numbers),
        processed = {},
        rolledNumbers = copyArray(storedCard.rolledNumbers),
        selectedIndices = selectedMapFromArray(storedCard.selectedIndices),
        sessionId = storedCard.sessionId,
        updatedAt = tonumber(storedCard.updatedAt) or 0,
    }

    if #card.numbers ~= 24 then
        return self:CreateFreshCard(self:GetSession().id)
    end

    card.processed = createProcessedMap(card.rolledNumbers)

    return card
end

function addon.RaidBingoService:SaveCard(card)
    local state = self:GetState()
    state.card = {
        locked = card.locked and true or false,
        numbers = copyArray(card.numbers),
        processed = createProcessedMap(card.rolledNumbers),
        rolledNumbers = copyArray(card.rolledNumbers),
        selectedIndices = selectedArrayFromMap(card.selectedIndices),
        sessionId = card.sessionId,
        updatedAt = time(),
    }
end

function addon.RaidBingoService:SaveSession(session)
    local state = self:GetState()
    local memberSelections = {}

    for sender, record in pairs(session.memberSelections or {}) do
        memberSelections[sender] = {
            matched = tonumber(record.matched) or 0,
            numbers = copyArray(record.numbers),
            receivedAt = tonumber(record.receivedAt) or time(),
            sender = normalizeName(record.sender or sender),
        }
    end

    state.session = {
        createdAt = tonumber(session.createdAt) or 0,
        id = session.id,
        leader = session.leader and normalizeName(session.leader) or nil,
        memberSelections = memberSelections,
        prizePool = normalizePrizePoolText(session.prizePool),
        rolledNumbers = copyArray(session.rolledNumbers),
        startedAt = tonumber(session.startedAt) or 0,
        status = session.status or STATE_IDLE,
    }
end

function addon.RaidBingoService:GetPlayerName()
    return addon.Store:GetPlayerName()
end

function addon.RaidBingoService:CanLead()
    if not IsInGroup() and not IsInRaid() then
        return true
    end

    if isInInstanceGroup() and UnitIsGroupLeader("player", LE_PARTY_CATEGORY_INSTANCE) then
        return true
    end

    return UnitIsGroupLeader("player") and true or false
end

function addon.RaidBingoService:GetLeaderName()
    if self:CanLead() then
        return self:GetPlayerName()
    end

    if IsInRaid() then
        for index = 1, GetNumGroupMembers() do
            if UnitIsGroupLeader("raid" .. index) then
                return normalizeName(GetRaidRosterInfo(index))
            end
        end
    end

    if IsInGroup() then
        for index = 1, GetNumSubgroupMembers() do
            local unitId = "party" .. index

            if UnitExists(unitId) and (UnitIsGroupLeader(unitId, LE_PARTY_CATEGORY_INSTANCE) or UnitIsGroupLeader(unitId)) then
                return normalizeName(UnitName(unitId))
            end
        end
    end

    local session = self:GetSession()
    return session.leader or self:GetPlayerName()
end

function addon.RaidBingoService:CreateFreshCard(sessionId)
    local columns = {
        { 1, 15 },
        { 16, 30 },
        { 31, 45 },
        { 46, 60 },
        { 61, 75 },
    }
    local numbers = {}

    for _, range in ipairs(columns) do
        local bag = {}

        for value = range[1], range[2] do
            bag[#bag + 1] = value
        end

        for _ = 1, 5 do
            local choiceIndex = math.random(1, #bag)
            numbers[#numbers + 1] = table.remove(bag, choiceIndex)
        end
    end

    local ordered = {}

    for row = 1, 5 do
        for column = 1, 5 do
            if not (row == 3 and column == 3) then
                local sourceIndex = ((column - 1) * 5) + row
                ordered[#ordered + 1] = numbers[sourceIndex]
            end
        end
    end

    local card = {
        locked = false,
        numbers = ordered,
        processed = {},
        rolledNumbers = {},
        selectedIndices = {},
        sessionId = sessionId,
        updatedAt = time(),
    }

    self:SaveCard(card)
    return self:GetCard()
end

function addon.RaidBingoService:RollNewCard(force)
    local card = self:GetCard()

    if card.locked and not force then
        return false
    end

    local session = self:GetSession()
    return self:CreateFreshCard(session.id)
end

function addon.RaidBingoService:GetCurrentSessionId()
    return self:GetSession().id
end

function addon.RaidBingoService:ResetLocalCard(sessionId)
    return self:CreateFreshCard(sessionId)
end

function addon.RaidBingoService:CreateSession()
    local prizePool = normalizePrizePoolText(self:GetState().ui.prizePool)
    local session = {
        createdAt = time(),
        id = addon.Repositories:NextId("raidbingo"),
        leader = self:GetPlayerName(),
        memberSelections = {},
        prizePool = prizePool,
        rolledNumbers = {},
        startedAt = time(),
        status = STATE_ACTIVE,
    }

    self:SaveSession(session)
    self:GetState().ui.prizePool = prizePool
    self:ResetLocalCard(session.id)
    return self:GetSession()
end

function addon.RaidBingoService:IsPrizePoolLocked()
    local session = self:GetSession()
    return session.id and session.status == STATE_ACTIVE and true or false
end

function addon.RaidBingoService:GetPrizePoolText()
    local session = self:GetSession()

    if session.id and session.status == STATE_ACTIVE then
        return normalizePrizePoolText(session.prizePool)
    end

    local state = self:GetState()
    local localPrizePool = normalizePrizePoolText(state.ui.prizePool)

    if localPrizePool ~= "" then
        return localPrizePool
    end

    return normalizePrizePoolText(session.prizePool)
end

function addon.RaidBingoService:SetPrizePool(prizePoolText)
    if self:IsPrizePoolLocked() then
        return false, "locked"
    end

    local normalizedPrizePool = normalizePrizePoolText(prizePoolText)
    self:GetState().ui.prizePool = normalizedPrizePool
    return true, normalizedPrizePool
end

function addon.RaidBingoService:EnsureSession()
    local session = self:GetSession()

    if session.id and session.status == STATE_ACTIVE then
        return session
    end

    return self:CreateSession()
end

function addon.RaidBingoService:SerializeState()
    local session = self:GetSession()
    self:MergePendingSelections(session)
    return encodeJson({
        id = session.id,
        leader = session.leader,
        memberSelections = session.memberSelections,
        prizePool = session.prizePool,
        rolledNumbers = session.rolledNumbers,
        startedAt = session.startedAt,
        status = session.status,
    })
end

function addon.RaidBingoService:BroadcastRollSnapshot(session)
    local activeSession = session or self:GetSession()
    local chunkBodies = buildRollChunkBodies(activeSession.id, activeSession.rolledNumbers)
    local totalParts = #chunkBodies

    if not activeSession.id then
        return false
    end

    for partIndex, body in ipairs(chunkBodies) do
        self:Send("ROLLSYNC", encodeRollChunk(activeSession.id, partIndex, totalParts, body))
    end

    return true
end

function addon.RaidBingoService:ApplyRollSnapshot(sessionId, rolledNumbers)
    if not sessionId then
        return false
    end

    local session = self:GetSession()

    if session.id ~= sessionId then
        session.id = sessionId
        session.status = STATE_ACTIVE
        session.startedAt = tonumber(session.startedAt) or time()
        session.rolledNumbers = {}
    end

    session.rolledNumbers = mergeRolledHistory(session.rolledNumbers, rolledNumbers)
    mergeLockedCardSelection(session, self:GetState().card or {}, self:GetPlayerName())
    self:MergePendingSelections(session)
    self:SaveSession(session)

    local card = self:GetCard()
    card.sessionId = sessionId
    card.rolledNumbers = copyArray(session.rolledNumbers)
    card.processed = createProcessedMap(card.rolledNumbers)
    self:SaveCard(card)
    return true
end

function addon.RaidBingoService:ApplyRollChunk(payloadText)
    local decoded = decodeRollChunk(payloadText)

    if not decoded or not decoded.sessionId then
        return false
    end

    local state = self:GetState()
    local sessionChunks = state.pendingRollChunks[decoded.sessionId] or {
        parts = {},
        totalParts = decoded.totalParts,
    }

    sessionChunks.totalParts = decoded.totalParts
    sessionChunks.parts[decoded.partIndex] = decoded.body or ""
    state.pendingRollChunks[decoded.sessionId] = sessionChunks

    for index = 1, sessionChunks.totalParts do
        if sessionChunks.parts[index] == nil then
            return true
        end
    end

    local csv = table.concat(sessionChunks.parts, ",")
    state.pendingRollChunks[decoded.sessionId] = nil
    return self:ApplyRollSnapshot(decoded.sessionId, decodeRollNumbers(csv))
end

function addon.RaidBingoService:Send(command, payload)
    if addon.IsSuspended and addon:IsSuspended() then
        return false, "GH3 is paused during combat"
    end

    local channel, target = getCommChannel()
    return sendAddonMessage(PREFIX, command .. ":" .. (payload or ""), channel, target)
end

function addon.RaidBingoService:GetWinningSelection()
    local session = self:GetSession()
    local winner = nil

    for sender, record in pairs(session.memberSelections or {}) do
        local playerName = normalizeName(sender)
        local matched = self:GetMatchCount(record.numbers)

        if matched >= 5 then
            local candidate = {
                matched = matched,
                numbers = copyArray(record.numbers),
                player = playerName,
                receivedAt = tonumber(record.receivedAt) or 0,
            }

            if not winner
                or candidate.receivedAt < winner.receivedAt
                or (candidate.receivedAt == winner.receivedAt and candidate.player < winner.player) then
                winner = candidate
            end
        end
    end

    return winner
end

function addon.RaidBingoService:AnnounceBingo(winner)
    if addon.IsSuspended and addon:IsSuspended() then
        return false
    end

    local channel, target = getCommChannel()
    local winnerName = winner and shortPlayerName(winner.player) or "Unknown"
    SendChatMessage(string.format("BINGO! %s reached 5 matches!", winnerName), channel, nil, target)
end

function addon.RaidBingoService:CheckForBingoWinner()
    local session = self:GetSession()

    if session.status ~= STATE_ACTIVE or not self:CanLead() then
        return nil
    end

    local winner = self:GetWinningSelection()

    if winner then
        self:AnnounceBingo(winner)
        self:EndSession(winner.player)
        return winner
    end

    return nil
end

function addon.RaidBingoService:BroadcastState()
    if addon.IsSuspended and addon:IsSuspended() then
        return self:GetSession()
    end

    local session = self:GetSession()

    if mergeLockedCardSelection(session, self:GetState().card or {}, self:GetPlayerName()) then
        self:SaveSession(session)
    end

    if self:MergePendingSelections(session) then
        self:SaveSession(session)
    end

    if session.id and session.status == STATE_ACTIVE then
        self:Send("STATE", encodeJson({
            id = session.id,
            leader = session.leader,
            memberSelections = session.memberSelections,
            prizePool = session.prizePool,
            rolledNumbers = session.rolledNumbers,
            startedAt = session.startedAt,
            status = session.status,
        }))
        self:BroadcastRollSnapshot(session)
    else
        self:Send("END", encodeJson({
            leader = self:GetLeaderName(),
            status = STATE_IDLE,
        }))
    end

    return session
end

function addon.RaidBingoService:RequestState()
    if addon.IsSuspended and addon:IsSuspended() then
        return false
    end

    self.lastStateRequestAt = time()
    self:Send("SYNCREQ", encodeJson({ requester = self:GetPlayerName() }))
end

function addon.RaidBingoService:RequestSelections()
    if addon.IsSuspended and addon:IsSuspended() then
        return false
    end

    self:Send("SYNCREQ", encodeJson({
        requester = self:GetPlayerName(),
        requestSelections = true,
        sessionId = self:GetSession().id,
    }))
end

function addon.RaidBingoService:MaybeRequestState()
    if addon.IsSuspended and addon:IsSuspended() then
        return false
    end

    if self:CanLead() or (not IsInGroup() and not IsInRaid()) then
        return false
    end

    local now = time()

    if self.lastStateRequestAt and (now - self.lastStateRequestAt) < 3 then
        return false
    end

    self:RequestState()
    return true
end

function addon.RaidBingoService:PersistLocalSelection(session, card)
    local activeSession = session or self:GetSession()
    local activeCard = card or self:GetCard()

    if not mergeLockedCardSelection(activeSession, {
        locked = activeCard.locked,
        numbers = activeCard.numbers,
        selectedIndices = activeCard.selectedIndices,
        sessionId = activeCard.sessionId,
        updatedAt = activeCard.updatedAt,
    }, self:GetPlayerName()) and not (activeSession.memberSelections and activeSession.memberSelections[normalizeName(self:GetPlayerName())]) then
        return false
    end

    self:SaveSession(activeSession)
    return true
end

function addon.RaidBingoService:BroadcastSelection()
    if addon.IsSuspended and addon:IsSuspended() then
        return false
    end

    local card = self:GetCard()
    local session = self:GetSession()
    local numbers = flattenSelectedNumbers(card)

    if #numbers ~= 5 then
        return false
    end

    if session.id and card.sessionId ~= session.id then
        card.sessionId = session.id
        self:SaveCard(card)
    end

    self:PersistLocalSelection(session, card)
    local playerName = self:GetPlayerName()

    self:Send("PICK", encodeJson({
        player = playerName,
        sessionId = card.sessionId or session.id,
        numbers = numbers,
    }))
    return true
end

function addon.RaidBingoService:ResetGame()
    if addon.IsSuspended and addon:IsSuspended() then
        return nil, "paused"
    end

    if not self:CanLead() then
        return nil
    end

    local session = self:CreateSession()
    self:Send("RESET", encodeJson({
        id = session.id,
        leader = session.leader,
        startedAt = session.startedAt,
    }))
    self:BroadcastState()
    self:RequestSelections()
    return session
end

function addon.RaidBingoService:EndSession(winnerName)
    if addon.IsSuspended and addon:IsSuspended() then
        return nil, "paused"
    end

    if not self:CanLead() then
        return nil, "leader-only"
    end

    local currentSession = self:GetSession()
    local card = self:GetCard()

    local session = {
        createdAt = tonumber(currentSession.createdAt) or time(),
        id = currentSession.id,
        leader = currentSession.leader or self:GetPlayerName(),
        memberSelections = currentSession.memberSelections or {},
        prizePool = normalizePrizePoolText(currentSession.prizePool),
        rolledNumbers = copyArray(currentSession.rolledNumbers),
        startedAt = tonumber(currentSession.startedAt) or 0,
        status = STATE_IDLE,
    }

    if card.sessionId == session.id then
        card.rolledNumbers = copyArray(session.rolledNumbers)
        card.processed = createProcessedMap(card.rolledNumbers)
        self:SaveCard(card)
    end

    self:SaveSession(session)
    self:Send("END", encodeJson({
        leader = session.leader,
        id = session.id,
        memberSelections = session.memberSelections,
        prizePool = session.prizePool,
        rolledNumbers = session.rolledNumbers,
        startedAt = session.startedAt,
        status = STATE_IDLE,
        winner = winnerName and normalizeName(winnerName) or nil,
    }))
    self:GetState().ui.prizePool = normalizePrizePoolText(session.prizePool)
    return self:GetSession()
end

function addon.RaidBingoService:RollNumber()
    if addon.IsSuspended and addon:IsSuspended() then
        return nil, "paused"
    end

    if not self:CanLead() then
        return nil, "leader-only"
    end

    local session = self:EnsureSession()
    local available = {}
    local used = createProcessedMap(session.rolledNumbers)

    for value = 1, 75 do
        if not used[value] then
            available[#available + 1] = value
        end
    end

    if #available == 0 then
        return nil, "complete"
    end

    local number = available[math.random(1, #available)]
    session.rolledNumbers[#session.rolledNumbers + 1] = number
    mergeLockedCardSelection(session, self:GetState().card or {}, self:GetPlayerName())
    self:SaveSession(session)

    local card = self:GetCard()
    card.sessionId = session.id
    card.rolledNumbers = copyArray(session.rolledNumbers)
    card.processed = createProcessedMap(card.rolledNumbers)
    self:SaveCard(card)

    self:Send("ROLL", encodeJson({ id = session.id, number = number }))
    self:BroadcastRollSnapshot(session)
    self:RequestSelections()
    return number
end

function addon.RaidBingoService:ApplyState(payload)
    if type(payload) ~= "table" or not payload.id then
        return false
    end

    local session = self:GetSession()
    local replacedSession = session.id ~= payload.id
    session.id = payload.id
    session.leader = normalizeName(payload.leader or self:GetLeaderName())
    session.prizePool = normalizePrizePoolText(payload.prizePool ~= nil and payload.prizePool or session.prizePool or self:GetState().ui.prizePool)
    session.rolledNumbers = mergeRolledHistory(replacedSession and {} or session.rolledNumbers, payload.rolledNumbers)
    session.startedAt = tonumber(payload.startedAt) or time()
    session.status = payload.status or STATE_ACTIVE

    if type(payload.memberSelections) == "table" then
        local normalizedSelections = {}

        for sender, record in pairs(payload.memberSelections) do
            local normalizedSender = normalizeName(record and record.sender or sender)
            normalizedSelections[normalizedSender] = normalizeSelectionRecord(record)
            normalizedSelections[normalizedSender].sender = normalizedSender
        end

        session.memberSelections = normalizedSelections
    elseif replacedSession then
        session.memberSelections = {}
    end

    mergeLockedCardSelection(session, self:GetState().card or {}, self:GetPlayerName())
    self:MergePendingSelections(session)

    self:SaveSession(session)
    self:GetState().ui.prizePool = normalizePrizePoolText(session.prizePool)

    local card = self:GetCard()
    local shouldResetCard = replacedSession and card.sessionId and card.sessionId ~= session.id

    if shouldResetCard then
        card = self:CreateFreshCard(session.id)
    else
        card.sessionId = session.id
    end

    card.rolledNumbers = copyArray(session.rolledNumbers)
    card.processed = createProcessedMap(card.rolledNumbers)
    self:SaveCard(card)

    if card.locked then
        self:BroadcastSelection()
    end

    return true
end

function addon.RaidBingoService:ApplyRoll(payload)
    if type(payload) ~= "table" or not payload.id or not payload.number then
        return false
    end

    local session = self:GetSession()

    if session.id ~= payload.id then
        session.id = payload.id
        session.leader = self:GetLeaderName()
        session.memberSelections = session.memberSelections or {}
        session.startedAt = time()
        session.status = STATE_ACTIVE
        session.rolledNumbers = {}
    end

    local alreadyRolled = false

    for _, value in ipairs(session.rolledNumbers) do
        if tonumber(value) == tonumber(payload.number) then
            alreadyRolled = true
            break
        end
    end

    if not alreadyRolled then
        session.rolledNumbers[#session.rolledNumbers + 1] = tonumber(payload.number)
        mergeLockedCardSelection(session, self:GetState().card or {}, self:GetPlayerName())
        self:MergePendingSelections(session)
        self:SaveSession(session)
    end

    local card = self:GetCard()
    card.sessionId = payload.id
    card.rolledNumbers = copyArray(session.rolledNumbers)
    card.processed = createProcessedMap(card.rolledNumbers)
    self:SaveCard(card)

    if card.locked and session.status == STATE_ACTIVE and card.sessionId == session.id then
        self:BroadcastSelection()
    end

    self:CheckForBingoWinner()
    return true
end

function addon.RaidBingoService:GetMatchCount(numbers)
    local session = self:GetSession()
    local rolledSet = createProcessedMap(session.rolledNumbers)
    local matchCount = 0

    for _, number in ipairs(numbers or {}) do
        if rolledSet[tonumber(number)] then
            matchCount = matchCount + 1
        end
    end

    return matchCount
end

function addon.RaidBingoService:ApplySelection(payload, sender)
    if type(payload) ~= "table" or not payload.sessionId then
        return false
    end

    local session = self:GetSession()
    local normalizedSender = normalizeName(payload.player or sender)
    local numbers = copyArray(payload.numbers)
    sortNumeric(numbers)
    local record = {
        matched = self:GetMatchCount(numbers),
        numbers = numbers,
        receivedAt = time(),
        sender = normalizedSender,
    }

    if session.id ~= payload.sessionId then
        self:QueuePendingSelection(payload.sessionId, normalizedSender, record)
        self:MaybeRequestState()
        return true
    end

    mergeSelectionRecord(session, normalizedSender, record)
    self:SaveSession(session)

    if self:CanLead() then
        self:BroadcastState()
    end

    self:CheckForBingoWinner()
    return true
end

function addon.RaidBingoService:ApplyReset(payload)
    if type(payload) ~= "table" or not payload.id then
        return false
    end

    local session = {
        createdAt = time(),
        id = payload.id,
        leader = normalizeName(payload.leader or self:GetLeaderName()),
        memberSelections = {},
        prizePool = normalizePrizePoolText(payload.prizePool ~= nil and payload.prizePool or self:GetState().ui.prizePool),
        rolledNumbers = {},
        startedAt = tonumber(payload.startedAt) or time(),
        status = STATE_ACTIVE,
    }

    self:SaveSession(session)
    self:GetState().ui.prizePool = normalizePrizePoolText(session.prizePool)
    self:ResetLocalCard(session.id)
    return true
end

function addon.RaidBingoService:ApplyEnd(payload)
    if payload and payload.winner then
        addon:Print(string.format("BINGO! %s reached 5 matches.", shortPlayerName(payload.winner)))
    end

    local currentSession = self:GetSession()
    local currentCard = self:GetCard()

    local session = {
        createdAt = tonumber(currentSession.createdAt) or time(),
        id = payload and payload.id or currentSession.id,
        leader = normalizeName(payload and payload.leader or currentSession.leader or self:GetLeaderName()),
        memberSelections = type(payload and payload.memberSelections) == "table" and payload.memberSelections or currentSession.memberSelections or {},
        prizePool = normalizePrizePoolText(payload and payload.prizePool or currentSession.prizePool),
        rolledNumbers = copyArray(payload and payload.rolledNumbers or currentSession.rolledNumbers),
        startedAt = tonumber(payload and payload.startedAt) or tonumber(currentSession.startedAt) or 0,
        status = STATE_IDLE,
    }

    if currentCard.sessionId == session.id then
        currentCard.rolledNumbers = copyArray(session.rolledNumbers)
        currentCard.processed = createProcessedMap(currentCard.rolledNumbers)
        self:SaveCard(currentCard)
    end

    self:SaveSession(session)
    self:GetState().ui.prizePool = normalizePrizePoolText(session.prizePool)
    return true
end

function addon.RaidBingoService:Initialize()
    self:GetSession()
    self:GetCard()
end

function addon.RaidBingoService:HandleIncoming(prefix, message, _, sender)
    if (addon.IsSuspended and addon:IsSuspended()) or prefix ~= PREFIX then
        return false
    end

    local command, payloadText = parseMessage(message)
    local payload = decodeJson(payloadText)

    if command == "STATE" then
        return self:ApplyState(payload)
    end

    if command == "ROLLSYNC" then
        return self:ApplyRollChunk(payloadText)
    end

    if command == "ROLL" then
        return self:ApplyRoll(payload)
    end

    if command == "PICK" then
        return self:ApplySelection(payload, sender)
    end

    if command == "RESET" then
        return self:ApplyReset(payload)
    end

    if command == "END" then
        return self:ApplyEnd(payload)
    end

    if command == "SYNCREQ" then
        if self:CanLead() then
            self:BroadcastState()
            self:BroadcastRollSnapshot()
        end

        local card = self:GetCard()
        if card.locked then
            self:BroadcastSelection()
        end

        return true
    end

    return false
end

function addon.RaidBingoService:HandleGroupUpdate()
    if addon.IsSuspended and addon:IsSuspended() then
        return
    end

    local session = self:GetSession()
    local card = self:GetCard()

    if self:CanLead() and session.status == STATE_ACTIVE then
        self:BroadcastState()
    elseif IsInGroup() or IsInRaid() then
        self:MaybeRequestState()
    end

    if card.locked and session.status == STATE_ACTIVE then
        self:BroadcastSelection()
    end
end

function addon.RaidBingoService:GetCardCells()
    local card = self:GetCard()
    local cells = {}
    local sourceIndex = 1

    for row = 1, 5 do
        for column = 1, 5 do
            if row == 3 and column == 3 then
                cells[#cells + 1] = {
                    free = true,
                    index = 13,
                    matched = false,
                    number = "FREE",
                    row = row,
                    selected = true,
                    column = column,
                }
            else
                local number = tonumber(card.numbers[sourceIndex])
                local matched = number and card.processed[number] and card.selectedIndices[sourceIndex] and true or false
                cells[#cells + 1] = {
                    free = false,
                    index = sourceIndex,
                    matched = matched,
                    number = number,
                    row = row,
                    selected = card.selectedIndices[sourceIndex] and true or false,
                    column = column,
                }
                sourceIndex = sourceIndex + 1
            end
        end
    end

    return cells
end

function addon.RaidBingoService:ToggleCardSelection(index)
    if addon.IsSuspended and addon:IsSuspended() then
        return false, "paused"
    end

    local card = self:GetCard()

    if card.locked or not card.numbers[index] then
        return false, card.locked and "locked" or "invalid"
    end

    if card.selectedIndices[index] then
        card.selectedIndices[index] = nil
    else
        if countSelected(card.selectedIndices) >= 5 then
            return false, "limit"
        end

        card.selectedIndices[index] = true
    end

    if countSelected(card.selectedIndices) == 5 then
        card.locked = true
        local session = self:GetSession()
        card.sessionId = session.id
        self:SaveCard(card)
        self:PersistLocalSelection(session, card)
        self:BroadcastSelection()
        return true, "locked"
    end

    self:SaveCard(card)
    return true
end

function addon.RaidBingoService:GetSelectionRows()
    local session = self:GetSession()
    local rows = {}
    local seenPlayers = {}

    for sender, record in pairs(session.memberSelections or {}) do
        local matchCount = self:GetMatchCount(record.numbers)
        local normalizedPlayer = normalizeName(sender)
        rows[#rows + 1] = {
            matched = matchCount,
            numbers = copyArray(record.numbers),
            player = normalizedPlayer,
            receivedAt = record.receivedAt,
        }
        seenPlayers[normalizedPlayer] = true
    end

    local card = self:GetCard()
    local localPlayer = self:GetPlayerName()

    if card.locked and not seenPlayers[localPlayer] then
        local localNumbers = flattenSelectedNumbers(card)

        if #localNumbers == 5 then
            rows[#rows + 1] = {
                matched = self:GetMatchCount(localNumbers),
                numbers = localNumbers,
                player = localPlayer,
                receivedAt = card.updatedAt or time(),
            }
        end
    end

    table.sort(rows, function(left, right)
        if left.matched == right.matched then
            return left.player < right.player
        end

        return left.matched > right.matched
    end)

    return rows
end

function addon.RaidBingoService:CalculatePayouts(prizePoolText)
    local prizePool = tonumber(prizePoolText or self:GetPrizePoolText())

    if not prizePool or prizePool <= 0 then
        return {}
    end

    local grouped = { [3] = {}, [4] = {}, [5] = {} }
    local totals = { [3] = 0, [4] = 0, [5] = 0 }

    for _, row in ipairs(self:GetSelectionRows()) do
        local key = math.min(5, math.max(0, row.matched))

        if grouped[key] then
            grouped[key][#grouped[key] + 1] = row
            totals[key] = totals[key] + 1
        end
    end

    local totalWeight = 0

    for matchCount, weight in pairs(PAYOUT_WEIGHTS) do
        if totals[matchCount] > 0 then
            totalWeight = totalWeight + weight
        end
    end

    if totalWeight == 0 then
        return {}
    end

    local payouts = {}

    for matchCount = 5, 3, -1 do
        if totals[matchCount] > 0 then
            local share = (prizePool * (PAYOUT_WEIGHTS[matchCount] / totalWeight)) / totals[matchCount]
            local roundedShare = math.floor(share + 0.5)

            for _, row in ipairs(grouped[matchCount]) do
                payouts[#payouts + 1] = {
                    amount = roundedShare,
                    matched = matchCount,
                    numbers = row.numbers,
                    player = row.player,
                }
            end
        end
    end

    table.sort(payouts, function(left, right)
        if left.matched == right.matched then
            return left.player < right.player
        end

        return left.matched > right.matched
    end)

    return payouts
end

function addon.RaidBingoService:PostPayouts(prizePoolText)
    if not self:CanLead() then
        return false, "leader-only"
    end

    local payouts = self:CalculatePayouts(prizePoolText)
    local channel, target = getCommChannel()

    if #payouts == 0 then
        addon:Print("No eligible Raid Bingo winners yet.")
        return false
    end

    for _, row in ipairs(payouts) do
        SendChatMessage(string.format("%s: %d matches - %d", normalizeName(row.player), row.matched, row.amount), channel, nil, target)
    end

    return true
end

function addon.RaidBingoService:GetExportText()
    local members = {}

    if IsInRaid() then
        for index = 1, GetNumGroupMembers() do
            local name = GetRaidRosterInfo(index)

            if name then
                members[#members + 1] = normalizeName(name)
            end
        end
    elseif IsInGroup() then
        members[#members + 1] = self:GetPlayerName()

        for index = 1, GetNumSubgroupMembers() do
            local unitId = "party" .. index

            if UnitExists(unitId) then
                members[#members + 1] = normalizeName(UnitName(unitId))
            end
        end
    else
        members[#members + 1] = self:GetPlayerName()
    end

    table.sort(members)
    return table.concat(members, "\n")
end

function addon.RaidBingoService:ShowExportDialog()
    local state = self:GetState()
    local settings = state.exportWindow or { width = 420, height = 320 }
    local frame = self.exportFrame

    if not frame then
        frame = CreateFrame("Frame", "GH3RaidBingoExportFrame", UIParent, "BackdropTemplate")
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", function(self)
            self:StopMovingOrSizing()
        end)
        frame:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        frame:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
        frame:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)

        local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
        title:SetText("Raid Bingo Export")
        title:SetFont("Fonts\\MORPHEUS.ttf", 18, "OUTLINE")
        title:SetTextColor(0.93, 0.84, 0.63, 1)
        frame.title = title

        local closeButton = CreateFrame("Button", nil, frame, "BackdropTemplate")
        closeButton:SetSize(26, 26)
        closeButton:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -8, -8)
        stylePlainButton(closeButton)
        closeButton:SetText("X")
        closeButton:SetScript("OnClick", function()
            frame:Hide()
        end)

        local scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
        scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -44)
        scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -38, 48)

        local editBox = CreateFrame("EditBox", nil, scrollFrame, "InputBoxTemplate")
        editBox:SetMultiLine(true)
        editBox:SetFontObject("ChatFontNormal")
        editBox:SetAutoFocus(false)
        editBox:SetWidth(340)
        editBox:SetScript("OnEscapePressed", function(self)
            self:ClearFocus()
        end)
        scrollFrame:SetScrollChild(editBox)
        frame.editBox = editBox

        local closeAction = CreateFrame("Button", nil, frame, "BackdropTemplate")
        closeAction:SetSize(120, 24)
        closeAction:SetPoint("BOTTOM", frame, "BOTTOM", 0, 14)
        stylePlainButton(closeAction)
        closeAction:SetText("Close")
        closeAction:SetScript("OnClick", function()
            frame:Hide()
        end)
        frame.closeAction = closeAction
        self.exportFrame = frame
    end

    frame:SetSize(settings.width or 420, settings.height or 320)
    frame:SetPoint("CENTER")
    frame.editBox:SetText(self:GetExportText())
    frame.editBox:HighlightText()
    frame.editBox:SetFocus()
    frame:Show()
end

function addon.RaidBingoService:GetSummary()
    local card = self:GetCard()
    local session = self:GetSession()
    local selections = self:GetSelectionRows()

    return {
        canLead = self:CanLead(),
        leader = self:GetLeaderName(),
        locked = card.locked,
        picked = countSelected(card.selectedIndices),
        prizePool = self:GetPrizePoolText(),
        prizePoolLocked = self:IsPrizePoolLocked(),
        rolled = #session.rolledNumbers,
        sessionId = session.id,
        startedAt = session.startedAt,
        status = session.status,
        submissions = #selections,
    }
end