-- Comm.lua - addon message protocol (host-authoritative).
-- Wire format per chunk:  <type>:<gen>:<i>:<n>:<payloadChunk>
-- type  S = full state (host -> group)   I = intent (player -> host)
local ADDON, ns = ...
local Comm = ns.Comm

local PREFIX = "BJTBL"
local CHUNK = 235          -- addon-message body cap is 255; leave room for "S:gen:i:n:"
local sendGen = 0
local inbox = {}   -- [sender..type..gen] = { n, parts = {} }

-- ─── Safe serializer (no loadstring on untrusted input) ────────────────────
-- Supports nil / boolean / number / string / table(array+hash).
local enc
local function encScalar(v)
    local t = type(v)
    if v == nil then return "~"
    elseif t == "boolean" then return v and "T" or "F"
    elseif t == "number" then return "N" .. string.format("%.14g", v) .. ";"
    elseif t == "string" then return "S" .. #v .. ":" .. v
    elseif t == "table" then return enc(v)
    end
    return "~"
end

-- Is tbl a pure 1..n array (no gaps, no non-integer keys)? Returns n, else nil.
local function arrayLen(t)
    local n = 0
    for k in pairs(t) do
        if type(k) ~= "number" or k % 1 ~= 0 or k < 1 then return nil end
        if k > n then n = k end
    end
    local count = 0
    for _ in pairs(t) do count = count + 1 end
    if count ~= n then return nil end     -- a hole (e.g. {[1],[3]}) -> treat as dict
    return n
end

enc = function(tbl)
    -- Arrays (card lists, log, …) use a positional "A" form so we don't pay ~4
    -- bytes per element encoding the index — that overhead dominated the state.
    local n = arrayLen(tbl)
    if n then
        local out = { "A" }
        for i = 1, n do out[#out + 1] = encScalar(tbl[i]) end
        out[#out + 1] = "E"
        return table.concat(out)
    end
    local out = { "D" }
    for k, v in pairs(tbl) do
        out[#out + 1] = encScalar(k)
        out[#out + 1] = encScalar(v)
    end
    out[#out + 1] = "E"
    return table.concat(out)
end

function Comm.Serialize(tbl)
    return enc(tbl)
end

-- Recursive-descent decoder. pos is a 1-based index into s.
local decVal
local function decAt(s, pos)
    local tag = s:sub(pos, pos)
    if tag == "~" then return nil, pos + 1
    elseif tag == "T" then return true, pos + 1
    elseif tag == "F" then return false, pos + 1
    elseif tag == "N" then
        local stop = s:find(";", pos + 1, true)
        local num = tonumber(s:sub(pos + 1, stop - 1))
        return num, stop + 1
    elseif tag == "S" then
        local colon = s:find(":", pos + 1, true)
        local len = tonumber(s:sub(pos + 1, colon - 1))
        local str = s:sub(colon + 1, colon + len)
        return str, colon + 1 + len
    elseif tag == "A" then
        local t = {}
        pos = pos + 1
        local i = 0
        while s:sub(pos, pos) ~= "E" do
            local v
            v, pos = decAt(s, pos)
            i = i + 1
            t[i] = v
        end
        return t, pos + 1
    elseif tag == "D" then
        local t = {}
        pos = pos + 1
        while s:sub(pos, pos) ~= "E" do
            local k, v
            k, pos = decAt(s, pos)
            v, pos = decAt(s, pos)
            t[k] = v
        end
        return t, pos + 1
    end
    return nil, pos + 1
end
decVal = decAt

function Comm.Deserialize(s)
    local ok, val = pcall(function()
        local v = decVal(s, 1)
        return v
    end)
    if ok then return val end
    return nil
end

-- ─── Channel helpers ───────────────────────────────────────────────────────
local function GroupChannel()
    if IsInRaid() then return "RAID"
    elseif IsInGroup() then return "PARTY"
    end
    return nil
end

-- ─── Reliable, paced outbound queue ────────────────────────────────────────
-- THE fix for "an action sometimes never updates / gets stuck": every chunk
-- goes through one FIFO pump that (a) reads SendAddonMessage's result code and
-- RE-SENDS a throttled chunk instead of silently losing it, and (b) paces sends
-- so we never overflow the channel in the first place. State broadcasts
-- coalesce (only the newest un-sent snapshot survives) so a rapid sequence can
-- never pile up; intents never coalesce. The pump can't wedge: a chunk that
-- keeps failing is dropped after MAX_TRIES so the queue always drains.
local SEND_GAP  = 0.10   -- seconds between sends while things flow (~10/s)
local FAIL_GAP  = 0.40   -- back off this long after a throttle/failure
local MAX_TRIES = 8      -- give up on one chunk after this many attempts
local outQueue  = {}     -- FIFO: { kind, text, channel, target, tries }
local scheduled = false  -- a pump is already pending

local pump
local function schedulePump(delay)
    if scheduled then return end
    scheduled = true
    if C_Timer and C_Timer.After then C_Timer.After(delay or SEND_GAP, pump)
    else pump() end       -- no timer API: drain synchronously (degenerate fallback)
end

-- Did SendAddonMessage report success? Handles every API shape defensively:
-- nil (very old client, no code) -> assume sent; boolean true; enum Success/0.
local function wasSent(ok, result)
    if not ok then return false end
    if result == nil or result == true then return true end
    local E = Enum and Enum.SendAddonMessageResult
    if E and result == E.Success then return true end
    return result == 0
end

pump = function()
    scheduled = false
    local item = outQueue[1]
    if not item then return end
    local ok, result
    if C_ChatInfo and C_ChatInfo.SendAddonMessage then
        ok, result = pcall(C_ChatInfo.SendAddonMessage, PREFIX, item.text, item.channel, item.target)
    end
    local sent = wasSent(ok, result)
    item.tries = (item.tries or 0) + 1
    if sent or item.tries >= MAX_TRIES then
        table.remove(outQueue, 1)         -- delivered, or gave up: never wedge
    end                                   -- else keep at head and retry
    if outQueue[1] then schedulePump(sent and SEND_GAP or FAIL_GAP) end
end

local function enqueue(kind, text, channel, target)
    if not channel then return end
    outQueue[#outQueue + 1] = { kind = kind, text = text, channel = channel, target = target, tries = 0 }
    schedulePump(0)
end

-- Drop not-yet-sent STATE chunks: a newer snapshot supersedes them, so there's
-- no point spending channel budget on stale state (intents are never dropped).
local function dropPendingState()
    for i = #outQueue, 1, -1 do
        if outQueue[i].kind == "state" then table.remove(outQueue, i) end
    end
end

-- Split a payload into chunks and enqueue them (each tagged with its kind).
local function SendChunked(kind, msgType, payload, channel, target)
    if not channel then return end
    sendGen = (sendGen + 1) % 100000
    local n = math.max(1, math.ceil(#payload / CHUNK))
    for i = 1, n do
        local part = payload:sub((i - 1) * CHUNK + 1, i * CHUNK)
        local text = msgType .. ":" .. sendGen .. ":" .. i .. ":" .. n .. ":" .. part
        enqueue(kind, text, channel, target)
    end
end

-- ─── Public API ────────────────────────────────────────────────────────────
function Comm.BroadcastState(stateTbl)
    local ch = GroupChannel()
    if not ch then return end                       -- solo: nothing to broadcast
    dropPendingState()                              -- coalesce to the newest state
    SendChunked("state", "S", Comm.Serialize(stateTbl), ch)
end

-- Player sends an intent to the host. `to` (optional) names the table host this
-- intent is addressed to, so other hosts in the same group ignore it.
function Comm.SendIntent(action, data, to)
    local ch = GroupChannel()
    if not ch then return false end -- solo handled by caller (local apply)
    local payload = Comm.Serialize({ action = action, data = data, from = ns.Me(), to = to })
    SendChunked("intent", "I", payload, ch)
    return true
end

-- ─── Receive / reassembly ──────────────────────────────────────────────────
local function HandleComplete(msgType, sender, payload)
    if msgType == "S" then
        if ns.Engine.OnStateReceived then
            ns.Engine.OnStateReceived(sender, Comm.Deserialize(payload))
        end
    elseif msgType == "I" then
        if ns.Engine.OnIntentReceived then
            ns.Engine.OnIntentReceived(sender, Comm.Deserialize(payload))
        end
    end
end

local function OnAddonMessage(prefix, text, _, sender)
    if prefix ~= PREFIX then return end
    -- Group addon messages echo back to the sender; ignore our own (the realm
    -- suffix means a raw == comparison would miss it).
    if sender and (sender:gsub("%-.*$", "")) == UnitName("player") then return end
    local mtype, gen, i, n, rest = text:match("^(%a):(%d+):(%d+):(%d+):(.*)$")
    if not mtype then return end
    i, n = tonumber(i), tonumber(n)
    local key = sender .. mtype .. gen
    if n == 1 then
        HandleComplete(mtype, sender, rest)
        return
    end
    local box = inbox[key]
    if not box then
        -- A newer generation supersedes any older INCOMPLETE reassembly from the
        -- same sender+type: drop it so a single lost chunk can't orphan a partial
        -- state forever (the previous "stuck on a stale snapshot" failure mode).
        for k, b in pairs(inbox) do
            if b.sender == sender and b.mtype == mtype then inbox[k] = nil end
        end
        box = { n = n, parts = {}, count = 0, sender = sender, mtype = mtype }
        inbox[key] = box
    end
    if not box.parts[i] then
        box.parts[i] = rest
        box.count = box.count + 1
    end
    if box.count == box.n then
        inbox[key] = nil
        HandleComplete(mtype, sender, table.concat(box.parts))
    end
end

-- Watch the system channel for RandomRoll results. A roll is how the host seeds
-- the shared shoe: the server broadcasts it to the whole group reliably and off
-- the addon rate limit, so every client witnesses the same seed.
local function OnSystemMessage(text)
    local sender, value, low, high = ns.ParseRoll(text)
    if not sender then return end
    if ns.Engine and ns.Engine.OnSeedRoll then
        ns.Engine.OnSeedRoll(sender, value, low, high)
    end
end

function Comm.Register()
    if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then
        C_ChatInfo.RegisterAddonMessagePrefix(PREFIX)
    end
    local f = CreateFrame("Frame")
    f:RegisterEvent("CHAT_MSG_ADDON")
    f:RegisterEvent("CHAT_MSG_SYSTEM")
    f:SetScript("OnEvent", function(_, event, ...)
        if event == "CHAT_MSG_ADDON" then
            OnAddonMessage(...)
        elseif event == "CHAT_MSG_SYSTEM" then
            OnSystemMessage(...)   -- first vararg is the message text
        end
    end)
    Comm.frame = f
end
