local ADDON_NAME, NS = ...

local PREFIX = "EKH12"
local SEP = string.char(31)
local DEFAULT_CONFIG = {
    maxPosts = 0,
    pruneDays = 0,
    showLoginMessage = true,
    sortNewestFirst = true,
    allowedPostRanks = { 1 },
    allowedDeleteRanks = {},
    allowedCommunicationViewRanks = {},
    communicationCode = "eerstkoffie",
    fontType = "frizqt",
    fontSize = 14,
    launcherPoint = "TOPLEFT",
    launcherRelativePoint = "TOPLEFT",
    launcherX = 10,
    launcherY = -180,
    launcherScale = 0.5,
    mainFrameWidth = 860,
    mainFrameHeight = 620,
    mainFrameScale = 1,
    discordHeaderUrl = "https://dsc.gg/eerstkoffie",
}

local FONT_TYPE_OPTIONS = {
    frizqt = true,
    arialn = true,
    morpheus = true,
    skurri = true,
}

local ANCHOR_POINT_OPTIONS = {
    TOPLEFT = true,
    TOP = true,
    TOPRIGHT = true,
    LEFT = true,
    CENTER = true,
    RIGHT = true,
    BOTTOMLEFT = true,
    BOTTOM = true,
    BOTTOMRIGHT = true,
}

local function sanitizeFontTypeValue(value)
    local raw = tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "")
    if raw == "" then
        return DEFAULT_CONFIG.fontType
    end

    local normalized = raw:lower():gsub("%s+", "")
    if FONT_TYPE_OPTIONS[normalized] then
        return normalized
    end

    if raw:match("^sm:.+") then
        return raw
    end

    return DEFAULT_CONFIG.fontType
end

local function sanitizeCommunicationCodeValue(value)
    local raw = tostring(value or "")
    raw = raw:gsub("^%s+", ""):gsub("%s+$", "")
    raw = raw:gsub("%s+", "-")
    raw = raw:gsub("[^%w%-%._]", "")
    raw = raw:lower()

    if raw == "" then
        return DEFAULT_CONFIG.communicationCode
    end

    if #raw > 48 then
        raw = string.sub(raw, 1, 48)
    end

    return raw
end

local function sanitizeDiscordHeaderUrlValue(value)
    local raw = tostring(value or "")
    raw = raw:gsub("^%s+", ""):gsub("%s+$", "")

    if raw == "" then
        return DEFAULT_CONFIG.discordHeaderUrl
    end

    if #raw > 220 then
        raw = string.sub(raw, 1, 220)
    end

    if not string.match(raw, "^https?://") then
        raw = "https://" .. raw
    end

    if not string.match(raw, "^https?://[%w%-%._~:/%?#%[%]@!$&'()*+,;=%%]+$") then
        return DEFAULT_CONFIG.discordHeaderUrl
    end

    return raw
end

local function sanitizeAnchorPointValue(value, fallback)
    local raw = tostring(value or "")
    raw = raw:gsub("^%s+", ""):gsub("%s+$", "")
    raw = raw:upper()

    if ANCHOR_POINT_OPTIONS[raw] then
        return raw
    end

    return fallback or DEFAULT_CONFIG.launcherPoint
end

local function copyNumberArray(values)
    local out = {}
    for i = 1, #(values or {}) do
        out[i] = values[i]
    end
    return out
end

local function sanitizeAllowedPostRanksValue(value)
    if value == nil then
        return copyNumberArray(DEFAULT_CONFIG.allowedPostRanks)
    end

    local rawValues = value
    if type(value) == "string" then
        rawValues = {}
        for token in string.gmatch(value, "[^,]+") do
            table.insert(rawValues, token)
        end
    end

    if type(rawValues) ~= "table" then
        return copyNumberArray(DEFAULT_CONFIG.allowedPostRanks)
    end

    local seen = {}
    local out = {}
    for _, entry in ipairs(rawValues) do
        local rankIndex = math.floor(tonumber(entry) or -1)
        if rankIndex >= 1 and rankIndex <= 20 and not seen[rankIndex] then
            seen[rankIndex] = true
            table.insert(out, rankIndex)
        end
    end

    table.sort(out)
    return out
end

local function sanitizeAllowedDeleteRanksValue(value)
    if value == nil then
        return copyNumberArray(DEFAULT_CONFIG.allowedDeleteRanks)
    end

    local rawValues = value
    if type(value) == "string" then
        rawValues = {}
        for token in string.gmatch(value, "[^,]+") do
            table.insert(rawValues, token)
        end
    end

    if type(rawValues) ~= "table" then
        return copyNumberArray(DEFAULT_CONFIG.allowedDeleteRanks)
    end

    local seen = {}
    local out = {}
    for _, entry in ipairs(rawValues) do
        local rankIndex = math.floor(tonumber(entry) or -1)
        if rankIndex >= 1 and rankIndex <= 20 and not seen[rankIndex] then
            seen[rankIndex] = true
            table.insert(out, rankIndex)
        end
    end

    table.sort(out)
    return out
end

local function sanitizeAllowedCommunicationViewRanksValue(value)
    if value == nil then
        return copyNumberArray(DEFAULT_CONFIG.allowedCommunicationViewRanks)
    end

    local rawValues = value
    if type(value) == "string" then
        rawValues = {}
        for token in string.gmatch(value, "[^,]+") do
            table.insert(rawValues, token)
        end
    end

    if type(rawValues) ~= "table" then
        return copyNumberArray(DEFAULT_CONFIG.allowedCommunicationViewRanks)
    end

    local seen = {}
    local out = {}
    for _, entry in ipairs(rawValues) do
        local rankIndex = math.floor(tonumber(entry) or -1)
        if rankIndex >= 1 and rankIndex <= 20 and not seen[rankIndex] then
            seen[rankIndex] = true
            table.insert(out, rankIndex)
        end
    end

    table.sort(out)
    return out
end

local eventFrame = CreateFrame("Frame")
local rosterCache = {}
local incomingChunks = {}
local getRequestTracker = {}
local peerActivityTracker = {}
local rankIsGuildMaster
local guildHelloSent = false
local HELLO_RESYNC_SECONDS = 300
local GET_RATE_WINDOW_SECONDS = 10
local GET_RATE_MAX_REQUESTS = 20
local GET_REQUEST_INTERVAL_SECONDS = (GET_RATE_WINDOW_SECONDS / GET_RATE_MAX_REQUESTS) + 0.05
local PEER_ACTIVITY_WINDOW_SECONDS = 900
local buildGmotdText
local setGuildMotd

local function msg(text)
    DEFAULT_CHAT_FRAME:AddMessage("|cff66ccffEerst Koffie Headlines:|r " .. text)
end

local function normalizeName(name)
    if not name or name == "" then
        return ""
    end

    local n = name:lower()
    return n:gsub("%s+", "")
end

local function splitFullName(name)
    local raw = tostring(name or ""):gsub("^%s+", ""):gsub("%s+$", "")
    if raw == "" then
        return "", ""
    end

    local base, realm = raw:match("^([^%-]+)%-(.+)$")
    if base and realm then
        return base, realm
    end

    return raw, ""
end

local function getDisplayNameForSender(sender)
    local baseName, senderRealm = splitFullName(sender)
    if baseName == "" then
        return ""
    end

    local playerRealm = ""
    if GetRealmName then
        playerRealm = tostring(GetRealmName() or "")
    end

    local sameRealm = senderRealm == ""
        or normalizeName(senderRealm:gsub("%s+", "")) == normalizeName(playerRealm:gsub("%s+", ""))

    if sameRealm then
        if Ambiguate then
            return Ambiguate(baseName, "short")
        end
        return baseName
    end

    return baseName .. "-" .. senderRealm
end

local function buildNameLookupKeys(name)
    local out = {}
    local seen = {}

    local function push(rawName)
        local raw = tostring(rawName or ""):gsub("^%s+", ""):gsub("%s+$", "")
        if raw == "" then
            return
        end

        local normalized = normalizeName(raw)
        if normalized ~= "" and not seen[normalized] then
            seen[normalized] = true
            table.insert(out, normalized)
        end

        local shortRaw = raw:gsub("%-.+$", "")
        local shortNormalized = normalizeName(shortRaw)
        if shortNormalized ~= "" and not seen[shortNormalized] then
            seen[shortNormalized] = true
            table.insert(out, shortNormalized)
        end
    end

    push(name)
    if Ambiguate then
        push(Ambiguate(name, "short"))
    end

    return out
end

local function isSamePlayer(sender)
    if not sender then
        return false
    end

    local shortSender = Ambiguate(sender, "short")
    local me = UnitName("player")
    return normalizeName(shortSender) == normalizeName(me)
end

local function encodeValue(value)
    value = tostring(value or "")
    return (value:gsub("([^%w%-%._~ ])", function(ch)
        return string.format("%%%02X", string.byte(ch))
    end):gsub(" ", "%%20"))
end

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

local function countMapEntries(map)
    local count = 0
    for _ in pairs(map or {}) do
        count = count + 1
    end
    return count
end

local function getReaderKey(name)
    local shortName = name
    if Ambiguate then
        shortName = Ambiguate(name or "", "short")
    end
    return normalizeName(shortName)
end

local function sanitizeReadBy(value)
    local out = {}

    if type(value) == "table" then
        for key, isRead in pairs(value) do
            if isRead then
                local normalized = getReaderKey(key)
                if normalized ~= "" then
                    out[normalized] = true
                end
            end
        end
    elseif type(value) == "string" and value ~= "" then
        for token in string.gmatch(value, "[^,]+") do
            local normalized = getReaderKey(token)
            if normalized ~= "" then
                out[normalized] = true
            end
        end
    end

    return out
end

local function serializeReadBy(readBy)
    local readers = {}
    for readerKey, isRead in pairs(readBy or {}) do
        if isRead then
            table.insert(readers, readerKey)
        end
    end

    table.sort(readers)
    return table.concat(readers, ",")
end

local function sanitizeLikedBy(value)
    local out = {}

    if type(value) == "table" then
        for key, isLiked in pairs(value) do
            if isLiked then
                local normalized = getReaderKey(key)
                if normalized ~= "" then
                    out[normalized] = true
                end
            end
        end
    elseif type(value) == "string" and value ~= "" then
        for token in string.gmatch(value, "[^,]+") do
            local normalized = getReaderKey(token)
            if normalized ~= "" then
                out[normalized] = true
            end
        end
    end

    return out
end

local function serializeLikedBy(likedBy)
    local likers = {}
    for likerKey, isLiked in pairs(likedBy or {}) do
        if isLiked then
            table.insert(likers, likerKey)
        end
    end

    table.sort(likers)
    return table.concat(likers, ",")
end

-- Comments are serialized as individual records separated by \x02.
-- New format per record: id, author, timestamp, text, parentId joined by \x01.
-- Legacy format: author, timestamp, text (will be upgraded in-memory).
local COMMENT_FIELD_SEP = string.char(1)
local COMMENT_RECORD_SEP = string.char(2)

local function buildCommentId(author)
    local playerName = author or UnitName("player") or "unknown"
    return string.format(
        "c-%d-%d-%s",
        time(),
        math.random(1000, 999999),
        normalizeName(playerName)
    )
end

local function serializeComments(comments)
    if not comments or #comments == 0 then
        return ""
    end
    local records = {}
    for _, c in ipairs(comments) do
        local record = encodeValue(c.id or "")
            .. COMMENT_FIELD_SEP
            .. encodeValue(c.author or "")
            .. COMMENT_FIELD_SEP
            .. encodeValue(tostring(c.timestamp or 0))
            .. COMMENT_FIELD_SEP
            .. encodeValue(c.text or "")
            .. COMMENT_FIELD_SEP
            .. encodeValue(c.parentId or "")
        table.insert(records, record)
    end
    return table.concat(records, COMMENT_RECORD_SEP)
end

local function deserializeComments(str)
    if not str or str == "" then
        return {}
    end
    local out = {}
    local start = 1
    while true do
        local pos = string.find(str, COMMENT_RECORD_SEP, start, true)
        local record
        if pos then
            record = string.sub(str, start, pos - 1)
            start = pos + #COMMENT_RECORD_SEP
        else
            record = string.sub(str, start)
        end

        if record and record ~= "" then
        local sep1 = string.find(record, COMMENT_FIELD_SEP, 1, true)
        if sep1 then
            local sep2 = string.find(record, COMMENT_FIELD_SEP, sep1 + 1, true)
            if sep2 then
                local sep3 = string.find(record, COMMENT_FIELD_SEP, sep2 + 1, true)
                local sep4 = sep3 and string.find(record, COMMENT_FIELD_SEP, sep3 + 1, true) or nil

                if sep3 and sep4 then
                    -- New 5-field format
                    local commentId = decodeValue(string.sub(record, 1, sep1 - 1))
                    local author = decodeValue(string.sub(record, sep1 + 1, sep2 - 1))
                    local ts = tonumber(decodeValue(string.sub(record, sep2 + 1, sep3 - 1))) or 0
                    local text = decodeValue(string.sub(record, sep3 + 1, sep4 - 1))
                    local parentId = decodeValue(string.sub(record, sep4 + 1))
                    if author ~= "" and text ~= "" then
                        table.insert(out, {
                            id = commentId,
                            author = author,
                            timestamp = ts,
                            text = text,
                            parentId = parentId,
                        })
                    end
                else
                    -- Legacy 3-field format
                    local author = decodeValue(string.sub(record, 1, sep1 - 1))
                    local ts = tonumber(decodeValue(string.sub(record, sep1 + 1, sep2 - 1))) or 0
                    local text = decodeValue(string.sub(record, sep2 + 1))
                    if author ~= "" and text ~= "" then
                        table.insert(out, {
                            id = "",
                            author = author,
                            timestamp = ts,
                            text = text,
                            parentId = "",
                        })
                    end
                end
            end
        end
        end

        if not pos then
            break
        end
    end
    return out
end

local function sanitizeComments(value)
    if type(value) ~= "table" then
        return {}
    end
    local out = {}
    local seen = {}
    for _, c in ipairs(value) do
        if type(c) == "table" and type(c.author) == "string" and c.author ~= ""
            and type(c.text) == "string" and c.text ~= "" then
            local commentId = tostring(c.id or ""):gsub("^%s+", ""):gsub("%s+$", "")
            if commentId == "" then
                commentId = string.format(
                    "legacy-%s-%d",
                    getReaderKey(c.author),
                    tonumber(c.timestamp) or 0
                )
            end

            if not seen[commentId] then
                seen[commentId] = true
                local parentId = tostring(c.parentId or "")
                if parentId == commentId then
                    parentId = ""
                end
            table.insert(out, {
                id = commentId,
                author = c.author,
                timestamp = tonumber(c.timestamp) or 0,
                text = c.text,
                    parentId = parentId,
            })
            end
        end
    end
    table.sort(out, function(a, b)
        if a.timestamp == b.timestamp then
            return a.id < b.id
        end
        return a.timestamp < b.timestamp
    end)
    return out
end

local function split(input, delimiter)
    local out = {}
    local start = 1
    while true do
        local pos = string.find(input, delimiter, start, true)
        if not pos then
            table.insert(out, string.sub(input, start))
            break
        end
        table.insert(out, string.sub(input, start, pos - 1))
        start = pos + #delimiter
    end
    return out
end

local function joinPayload(...)
    local vals = { ... }
    for i = 1, #vals do
        vals[i] = encodeValue(vals[i])
    end
    return table.concat(vals, "\t")
end

local function parsePayload(payload)
    local values = split(payload or "", "\t")
    for i = 1, #values do
        values[i] = decodeValue(values[i])
    end
    return values
end

local function buildPostId()
    local playerName = UnitName("player") or "unknown"
    return string.format("%d-%d-%s", time(), math.random(1000, 999999), normalizeName(playerName))
end

local function ensureDb()
    if not EerstKoffieHeadlinesDB then
        EerstKoffieHeadlinesDB = {}
    end

    local db = EerstKoffieHeadlinesDB
    db.posts = db.posts or {}
    db.order = db.order or {}
    db.revision = db.revision or 1
    db.config = db.config or {}
    db.lastGmotdPostId = db.lastGmotdPostId or nil
    db.lastGmotdCreatedAt = tonumber(db.lastGmotdCreatedAt) or 0

    if type(db.config.maxPosts) ~= "number" then
        db.config.maxPosts = DEFAULT_CONFIG.maxPosts
    end
    if type(db.config.pruneDays) ~= "number" then
        db.config.pruneDays = DEFAULT_CONFIG.pruneDays
    end
    if type(db.config.showLoginMessage) ~= "boolean" then
        db.config.showLoginMessage = DEFAULT_CONFIG.showLoginMessage
    end
    if type(db.config.sortNewestFirst) ~= "boolean" then
        db.config.sortNewestFirst = DEFAULT_CONFIG.sortNewestFirst
    end
    if type(db.config.allowedPostRanks) ~= "table" then
        db.config.allowedPostRanks = copyNumberArray(DEFAULT_CONFIG.allowedPostRanks)
    end
    if type(db.config.allowedDeleteRanks) ~= "table" then
        db.config.allowedDeleteRanks = copyNumberArray(DEFAULT_CONFIG.allowedDeleteRanks)
    end
    if type(db.config.allowedCommunicationViewRanks) ~= "table" then
        db.config.allowedCommunicationViewRanks = copyNumberArray(DEFAULT_CONFIG.allowedCommunicationViewRanks)
    end
    if type(db.config.communicationCode) ~= "string" or db.config.communicationCode == "" then
        db.config.communicationCode = DEFAULT_CONFIG.communicationCode
    end
    if type(db.config.fontType) ~= "string" or db.config.fontType == "" then
        db.config.fontType = DEFAULT_CONFIG.fontType
    end
    if type(db.config.fontSize) ~= "number" then
        db.config.fontSize = DEFAULT_CONFIG.fontSize
    end
    if type(db.config.launcherPoint) ~= "string" or db.config.launcherPoint == "" then
        db.config.launcherPoint = DEFAULT_CONFIG.launcherPoint
    end
    if type(db.config.launcherRelativePoint) ~= "string" or db.config.launcherRelativePoint == "" then
        db.config.launcherRelativePoint = DEFAULT_CONFIG.launcherRelativePoint
    end
    if type(db.config.launcherX) ~= "number" then
        db.config.launcherX = DEFAULT_CONFIG.launcherX
    end
    if type(db.config.launcherY) ~= "number" then
        db.config.launcherY = DEFAULT_CONFIG.launcherY
    end
    if type(db.config.launcherScale) ~= "number" then
        db.config.launcherScale = DEFAULT_CONFIG.launcherScale
    end
    if type(db.config.mainFrameWidth) ~= "number" then
        db.config.mainFrameWidth = DEFAULT_CONFIG.mainFrameWidth
    end
    if type(db.config.mainFrameHeight) ~= "number" then
        db.config.mainFrameHeight = DEFAULT_CONFIG.mainFrameHeight
    end
    if type(db.config.mainFrameScale) ~= "number" then
        db.config.mainFrameScale = DEFAULT_CONFIG.mainFrameScale
    end
    if type(db.config.discordHeaderUrl) ~= "string" or db.config.discordHeaderUrl == "" then
        db.config.discordHeaderUrl = DEFAULT_CONFIG.discordHeaderUrl
    end

    NS.db = db

    for id, post in pairs(db.posts) do
        if type(post) == "table" then
            post.id = post.id or id
            post.readBy = sanitizeReadBy(post.readBy)
            post.likedBy = sanitizeLikedBy(post.likedBy)
            post.comments = sanitizeComments(post.comments)
        end
    end
end

local function sanitizeConfig(nextConfig)
    local cfg = nextConfig or {}
    local maxPosts = math.floor(tonumber(cfg.maxPosts) or DEFAULT_CONFIG.maxPosts)
    local pruneDays = math.floor(tonumber(cfg.pruneDays) or DEFAULT_CONFIG.pruneDays)
    local allowedPostRanks = sanitizeAllowedPostRanksValue(cfg.allowedPostRanks)
    local allowedDeleteRanks = sanitizeAllowedDeleteRanksValue(cfg.allowedDeleteRanks)
    local allowedCommunicationViewRanks = sanitizeAllowedCommunicationViewRanksValue(cfg.allowedCommunicationViewRanks)
    local communicationCode = sanitizeCommunicationCodeValue(cfg.communicationCode)
    local discordHeaderUrl = sanitizeDiscordHeaderUrlValue(cfg.discordHeaderUrl)
    local fontType = sanitizeFontTypeValue(cfg.fontType)
    local fontSize = math.floor(tonumber(cfg.fontSize) or DEFAULT_CONFIG.fontSize)
    local launcherPoint = sanitizeAnchorPointValue(cfg.launcherPoint, DEFAULT_CONFIG.launcherPoint)
    local launcherRelativePoint = sanitizeAnchorPointValue(cfg.launcherRelativePoint, launcherPoint)

    if maxPosts < 0 then
        maxPosts = 0
    end

    if pruneDays < 0 then
        pruneDays = 0
    elseif pruneDays > 365 then
        pruneDays = 365
    end

    if fontSize < 10 then
        fontSize = 10
    elseif fontSize > 24 then
        fontSize = 24
    end

    local launcherX = tonumber(cfg.launcherX) or DEFAULT_CONFIG.launcherX
    local launcherY = tonumber(cfg.launcherY) or DEFAULT_CONFIG.launcherY
    local launcherScale = tonumber(cfg.launcherScale) or DEFAULT_CONFIG.launcherScale
    local mainFrameWidth = tonumber(cfg.mainFrameWidth) or DEFAULT_CONFIG.mainFrameWidth
    local mainFrameHeight = tonumber(cfg.mainFrameHeight) or DEFAULT_CONFIG.mainFrameHeight
    local mainFrameScale = tonumber(cfg.mainFrameScale) or DEFAULT_CONFIG.mainFrameScale

    if launcherX ~= launcherX or launcherX == math.huge or launcherX == -math.huge then
        launcherX = DEFAULT_CONFIG.launcherX
    end

    if launcherY ~= launcherY or launcherY == math.huge or launcherY == -math.huge then
        launcherY = DEFAULT_CONFIG.launcherY
    end

    if launcherScale < 0.5 then
        launcherScale = 0.5
    elseif launcherScale > 2.0 then
        launcherScale = 2.0
    end

    if mainFrameWidth < 200 then
        mainFrameWidth = 200
    end

    if mainFrameHeight < 200 then
        mainFrameHeight = 200
    end

    if mainFrameScale <= 0 then
        mainFrameScale = DEFAULT_CONFIG.mainFrameScale
    end

    return {
        maxPosts = maxPosts,
        pruneDays = pruneDays,
        showLoginMessage = cfg.showLoginMessage ~= false,
        sortNewestFirst = cfg.sortNewestFirst ~= false,
        allowedPostRanks = allowedPostRanks,
        allowedDeleteRanks = allowedDeleteRanks,
        allowedCommunicationViewRanks = allowedCommunicationViewRanks,
        communicationCode = communicationCode,
        fontType = fontType,
        fontSize = fontSize,
        launcherPoint = launcherPoint,
        launcherRelativePoint = launcherRelativePoint,
        launcherX = launcherX,
        launcherY = launcherY,
        launcherScale = launcherScale,
        mainFrameWidth = mainFrameWidth,
        mainFrameHeight = mainFrameHeight,
        mainFrameScale = mainFrameScale,
        discordHeaderUrl = discordHeaderUrl,
    }
end

local function getCommunicationCode()
    if not NS.db or not NS.db.config then
        return DEFAULT_CONFIG.communicationCode
    end

    return sanitizeCommunicationCodeValue(NS.db.config.communicationCode)
end

local function getAllowedPostRanks()
    if not NS.db or not NS.db.config then
        return copyNumberArray(DEFAULT_CONFIG.allowedPostRanks)
    end

    return sanitizeAllowedPostRanksValue(NS.db.config.allowedPostRanks)
end

local function buildAllowedPostRanksLookup()
    local out = {}
    for _, rankIndex in ipairs(getAllowedPostRanks()) do
        out[rankIndex] = true
    end
    return out
end

local function getAllowedPostRanksCsv()
    return table.concat(getAllowedPostRanks(), ",")
end

local function getAllowedDeleteRanks()
    if not NS.db or not NS.db.config then
        return copyNumberArray(DEFAULT_CONFIG.allowedDeleteRanks)
    end

    return sanitizeAllowedDeleteRanksValue(NS.db.config.allowedDeleteRanks)
end

local function buildAllowedDeleteRanksLookup()
    local out = {}
    for _, rankIndex in ipairs(getAllowedDeleteRanks()) do
        out[rankIndex] = true
    end
    return out
end

local function getAllowedDeleteRanksCsv()
    return table.concat(getAllowedDeleteRanks(), ",")
end

local function getAllowedCommunicationViewRanks()
    if not NS.db or not NS.db.config then
        return copyNumberArray(DEFAULT_CONFIG.allowedCommunicationViewRanks)
    end

    return sanitizeAllowedCommunicationViewRanksValue(NS.db.config.allowedCommunicationViewRanks)
end

local function buildAllowedCommunicationViewRanksLookup()
    local out = {}
    for _, rankIndex in ipairs(getAllowedCommunicationViewRanks()) do
        out[rankIndex] = true
    end
    return out
end

local function getAllowedCommunicationViewRanksCsv()
    return table.concat(getAllowedCommunicationViewRanks(), ",")
end

local function getDiscordHeaderUrl()
    if not NS.db or not NS.db.config then
        return DEFAULT_CONFIG.discordHeaderUrl
    end

    return sanitizeDiscordHeaderUrlValue(NS.db.config.discordHeaderUrl)
end

local function getGuildName()
    return GetGuildInfo("player") or ""
end

local function buildWireRaw(command, payload)
    local code = getCommunicationCode()
    local guild = getGuildName()
    return table.concat({ code, guild, command or "", payload or "" }, SEP)
end

local function parseWireRaw(raw)
    local firstSep = string.find(raw or "", SEP, 1, true)
    if not firstSep then
        return nil
    end

    local secondSep = string.find(raw, SEP, firstSep + 1, true)
    if not secondSep then
        return nil
    end

    local thirdSep = string.find(raw, SEP, secondSep + 1, true)
    if not thirdSep then
        return nil
    end

    local code = string.sub(raw, 1, firstSep - 1)
    local guild = string.sub(raw, firstSep + 1, secondSep - 1)
    local command = string.sub(raw, secondSep + 1, thirdSep - 1)
    local payload = string.sub(raw, thirdSep + 1)
    return code, guild, command, payload
end

local function sortPosts()
    table.sort(NS.db.order, function(a, b)
        local pa = NS.db.posts[a]
        local pb = NS.db.posts[b]
        if not pa and not pb then
            return a > b
        end
        if not pa then
            return false
        end
        if not pb then
            return true
        end
        if pa.createdAt == pb.createdAt then
            return pa.id > pb.id
        end
        return pa.createdAt > pb.createdAt
    end)
end

local function rebuildOrder()
    local order = {}
    for id, _ in pairs(NS.db.posts) do
        table.insert(order, id)
    end
    NS.db.order = order
    sortPosts()
end

local function touchRevision()
    NS.db.revision = (NS.db.revision or 1) + 1
end

local function applyRetentionRules(mutateRevision)
    if not NS.db or not NS.db.config then
        return false
    end

    rebuildOrder()

    local changed = false
    local cutoff = nil
    if NS.db.config.pruneDays > 0 then
        cutoff = time() - (NS.db.config.pruneDays * 86400)
    end

    if cutoff then
        for i = #NS.db.order, 1, -1 do
            local id = NS.db.order[i]
            local post = NS.db.posts[id]
            if post and (post.createdAt or 0) < cutoff then
                NS.db.posts[id] = nil
                table.remove(NS.db.order, i)
                changed = true
            end
        end
    end

    local maxPosts = NS.db.config.maxPosts or DEFAULT_CONFIG.maxPosts
    if maxPosts > 0 and #NS.db.order > maxPosts then
        for i = #NS.db.order, maxPosts + 1, -1 do
            local id = NS.db.order[i]
            NS.db.posts[id] = nil
            table.remove(NS.db.order, i)
            changed = true
        end
    end

    if changed and mutateRevision then
        touchRevision()
    end

    return changed
end

local function rankCanManage(cached)
    if not cached then
        return false
    end

    if rankIsGuildMaster(cached) then
        return true
    end

    if cached.rankIndex == nil then
        return false
    end

    local allowed = buildAllowedPostRanksLookup()
    return allowed[cached.rankIndex] == true
end

local function canManagePostsLocal()
    if not IsInGuild() then
        return false
    end

    if IsGuildLeader and IsGuildLeader() then
        return true
    end

    local playerName = UnitName("player")
    if playerName and rosterCache[normalizeName(playerName)] then
        return rankCanManage(rosterCache[normalizeName(playerName)])
    end

    return false
end

local function canViewCommunicationMembersLocal()
    if not IsInGuild() then
        return false
    end

    if IsGuildLeader and IsGuildLeader() then
        return true
    end

    local playerName = UnitName("player")
    local localEntry = playerName and rosterCache[normalizeName(playerName)] or nil
    if not localEntry or localEntry.rankIndex == nil then
        return false
    end

    if localEntry.rankIndex == 0 then
        return true
    end

    local allowedLookup = buildAllowedCommunicationViewRanksLookup()
    return allowedLookup[localEntry.rankIndex] == true
end

local function updateRosterCache()
    wipe(rosterCache)

    if not IsInGuild() then
        return
    end

    local count = GetNumGuildMembers()
    for i = 1, count do
        local fullName, rankName, rankIndex, _, _, _, _, _, _, _, classFileName = GetGuildRosterInfo(i)
        if fullName then
            local entry = {
                rankLabel = rankName or "",
                rankName = (rankName or ""):lower(),
                rankIndex = rankIndex,
                classFileName = type(classFileName) == "string" and string.upper(classFileName) or nil,
            }

            for _, key in ipairs(buildNameLookupKeys(fullName)) do
                rosterCache[key] = entry
            end
        end
    end
end

local function getClassColorHexByName(name)
    local entry = nil
    local keys = buildNameLookupKeys(name)
    for i = 1, #keys do
        local cached = rosterCache[keys[i]]
        if cached then
            entry = cached
            break
        end
    end

    local classFileName = entry and entry.classFileName or nil
    if not classFileName or classFileName == "" then
        return nil
    end

    local colorInfo = RAID_CLASS_COLORS and RAID_CLASS_COLORS[classFileName]
    if colorInfo then
        local r = math.floor(((colorInfo.r or 1) * 255) + 0.5)
        local g = math.floor(((colorInfo.g or 1) * 255) + 0.5)
        local b = math.floor(((colorInfo.b or 1) * 255) + 0.5)
        return string.format("%02x%02x%02x", r, g, b)
    end

    if C_ClassColor and C_ClassColor.GetClassColor then
        local cc = C_ClassColor.GetClassColor(classFileName)
        if cc then
            local r = math.floor(((cc.r or 1) * 255) + 0.5)
            local g = math.floor(((cc.g or 1) * 255) + 0.5)
            local b = math.floor(((cc.b or 1) * 255) + 0.5)
            return string.format("%02x%02x%02x", r, g, b)
        end
    end

    return nil
end

local function senderCanManage(sender)
    if not sender or sender == "" then
        return false
    end

    local keys = buildNameLookupKeys(sender)
    for i = 1, #keys do
        local cached = rosterCache[keys[i]]
        if cached then
            return rankCanManage(cached)
        end
    end

    return false
end

local function markPeerActivity(sender)
    if not sender or sender == "" then
        return
    end

    local senderKey = normalizeName(sender)
    if senderKey == "" then
        return
    end

    local displayName = getDisplayNameForSender(sender)

    peerActivityTracker[senderKey] = {
        seenAt = time(),
        displayName = displayName,
    }
end

local function getCommunicatingClientCount()
    local now = time()
    local count = 0
    for _, activity in pairs(peerActivityTracker) do
        local seenAt = nil
        if type(activity) == "table" then
            seenAt = activity.seenAt
        else
            seenAt = activity
        end

        if now - (seenAt or 0) <= PEER_ACTIVITY_WINDOW_SECONDS then
            count = count + 1
        end
    end
    return count
end

local function getCommunicatingMemberNames()
    local now = time()
    local out = {}

    for senderKey, activity in pairs(peerActivityTracker) do
        local seenAt = nil
        local displayName = nil

        if type(activity) == "table" then
            seenAt = activity.seenAt
            displayName = activity.displayName
        else
            seenAt = activity
        end

        if now - (seenAt or 0) <= PEER_ACTIVITY_WINDOW_SECONDS then
            table.insert(out, displayName or senderKey)
        end
    end

    table.sort(out, function(a, b)
        return tostring(a):lower() < tostring(b):lower()
    end)

    return out
end

local function getAllOnlineGuildMembersData()
    if not IsInGuild() then
        return {}
    end

    local now = time()
    local out = {}
    local communicatingSet = {}

    local function markCommunicating(name)
        local keys = buildNameLookupKeys(name)
        for i = 1, #keys do
            communicatingSet[keys[i]] = true
        end
    end
    
    -- Add current player as communicating
    local playerName = UnitName("player")
    if playerName then
        markCommunicating(playerName)
    end
    
    -- Build a set of communicating members for quick lookup
    for senderKey, activity in pairs(peerActivityTracker) do
        local seenAt = nil
        local displayName = nil

        if type(activity) == "table" then
            seenAt = activity.seenAt
            displayName = activity.displayName
        else
            seenAt = activity
        end

        if now - (seenAt or 0) <= PEER_ACTIVITY_WINDOW_SECONDS then
            if displayName and displayName ~= "" then
                markCommunicating(displayName)
            end
            markCommunicating(senderKey)
        end
    end

    -- Iterate through all guild members
    local count = GetNumGuildMembers()
    for i = 1, count do
        local fullName, rankLabel, rankIndex, _, _, _, _, _, isOnline, _, classFileName = GetGuildRosterInfo(i)
        
        if fullName and isOnline then
            local isCommunicating = false
            local memberKeys = buildNameLookupKeys(fullName)
            for j = 1, #memberKeys do
                if communicatingSet[memberKeys[j]] then
                    isCommunicating = true
                    break
                end
            end
            
            table.insert(out, {
                name = fullName,
                rankLabel = rankLabel or "",
                rankIndex = rankIndex or 0,
                isCommunicating = isCommunicating,
                classFileName = type(classFileName) == "string" and string.upper(classFileName) or nil,
            })
        end
    end

    -- Sort by communication status first (communicating first), then by name
    table.sort(out, function(a, b)
        if a.isCommunicating ~= b.isCommunicating then
            return a.isCommunicating and not b.isCommunicating
        end
        return tostring(a.name):lower() < tostring(b.name):lower()
    end)

    return out
end

local function getOnlineGuildMemberCount()
    if not IsInGuild() then
        return 0
    end

    local total, online = GetNumGuildMembers()
    if type(online) == "number" then
        return math.max(0, math.floor(online))
    end

    if type(total) == "number" then
        return math.max(0, math.floor(total))
    end

    return 0
end

local function getRosterEntry(name)
    local keys = buildNameLookupKeys(name)
    for i = 1, #keys do
        local cached = rosterCache[keys[i]]
        if cached then
            return cached
        end
    end

    return nil
end

rankIsGuildMaster = function(cached)
    if not cached then
        return false
    end

    if cached.rankIndex ~= nil then
        return cached.rankIndex == 0
    end

    local rank = cached.rankName or ""
    return rank:find("master", 1, true) ~= nil
        or rank:find("leader", 1, true) ~= nil
        or rank:find("leider", 1, true) ~= nil
end

local function isLocalGuildMaster()
    if not IsInGuild() then
        return false
    end

    if IsGuildLeader and IsGuildLeader() then
        return true
    end

    return rankIsGuildMaster(getRosterEntry(UnitName("player")))
end

local function namesMatch(a, b)
    local aKeys = buildNameLookupKeys(a)
    if #aKeys == 0 then
        return false
    end

    local seen = {}
    for i = 1, #aKeys do
        seen[aKeys[i]] = true
    end

    local bKeys = buildNameLookupKeys(b)
    for i = 1, #bKeys do
        if seen[bKeys[i]] then
            return true
        end
    end

    return false
end

local function localCanDeletePost(postId)
    local post = NS.db.posts[postId]
    if not post then
        return false
    end

    if isLocalGuildMaster() then
        return true
    end

    if namesMatch(post.author, UnitName("player")) then
        return true
    end

    local localEntry = getRosterEntry(UnitName("player"))
    if not localEntry or localEntry.rankIndex == nil then
        return false
    end

    local allowedDeleteLookup = buildAllowedDeleteRanksLookup()
    return allowedDeleteLookup[localEntry.rankIndex] == true
end

local function senderCanDeletePost(sender, postId)
    local post = NS.db.posts[postId]
    if not post then
        return false
    end

    if rankIsGuildMaster(getRosterEntry(sender)) then
        return true
    end

    if namesMatch(post.author, sender) then
        return true
    end

    local senderEntry = getRosterEntry(sender)
    if not senderEntry or senderEntry.rankIndex == nil then
        return false
    end

    local allowedDeleteLookup = buildAllowedDeleteRanksLookup()
    return allowedDeleteLookup[senderEntry.rankIndex] == true
end

local function isKnownGuildSender(sender)
    if not sender or sender == "" then
        return false
    end

    local keys = buildNameLookupKeys(sender)
    for i = 1, #keys do
        if rosterCache[keys[i]] then
            return true
        end
    end

    return false
end

local function canServeGetRequest(sender)
    local senderKey = normalizeName(sender)
    if senderKey == "" then
        return false
    end

    local now = time()
    local entry = getRequestTracker[senderKey]

    if not entry or (now - entry.windowStart) >= GET_RATE_WINDOW_SECONDS then
        getRequestTracker[senderKey] = {
            windowStart = now,
            count = 1,
            lastSeen = now,
        }
        return true
    end

    if entry.count >= GET_RATE_MAX_REQUESTS then
        entry.lastSeen = now
        return false
    end

    entry.count = entry.count + 1
    entry.lastSeen = now
    return true
end

local function sendRaw(distribution, target, payload)
    if not IsInGuild() then
        return
    end

    if distribution == "WHISPER" then
        C_ChatInfo.SendAddonMessage(PREFIX, payload, distribution, target)
        return
    end

    C_ChatInfo.SendAddonMessage(PREFIX, payload, distribution)
end

local function sendCommand(distribution, target, command, payload)
    local raw = buildWireRaw(command, payload)
    local chunkSize = 210

    if #raw <= chunkSize then
        sendRaw(distribution, target, "MSG" .. SEP .. raw)
        return
    end

    local msgId = tostring(time()) .. "-" .. tostring(math.random(10000, 99999))
    local total = math.ceil(#raw / chunkSize)

    for idx = 1, total do
        local first = (idx - 1) * chunkSize + 1
        local part = string.sub(raw, first, first + chunkSize - 1)
        local chunkPayload = table.concat({ "CHUNK", msgId, idx, total, part }, SEP)
        sendRaw(distribution, target, chunkPayload)
    end
end

local function upsertPost(post, mutateRevision)
    if not post or not post.id then
        return
    end

    local existing = NS.db.posts[post.id]
    local incomingReadBy = sanitizeReadBy(post.readBy)
    if next(incomingReadBy) == nil and existing and existing.readBy then
        incomingReadBy = sanitizeReadBy(existing.readBy)
    end
    if next(incomingReadBy) == nil then
        local authorReaderKey = getReaderKey(post.author)
        if authorReaderKey ~= "" then
            incomingReadBy[authorReaderKey] = true
        end
    end

    local incomingLikedBy = sanitizeLikedBy(post.likedBy)
    if next(incomingLikedBy) == nil and existing and existing.likedBy then
        incomingLikedBy = sanitizeLikedBy(existing.likedBy)
    end

    -- Merge comments: union of existing + incoming, deduplicated by comment id.
    local incomingComments = sanitizeComments(post.comments)
    local mergedComments = {}
    local commentSeen = {}
    local function addComment(c)
        local key = tostring(c.id or "")
        if key == "" then
            key = string.format("legacy-%s-%d", getReaderKey(c.author), tonumber(c.timestamp) or 0)
        end
        if not commentSeen[key] then
            commentSeen[key] = true
            table.insert(mergedComments, c)
        end
    end
    if existing and existing.comments then
        for _, c in ipairs(existing.comments) do addComment(c) end
    end
    for _, c in ipairs(incomingComments) do addComment(c) end
    table.sort(mergedComments, function(a, b) return a.timestamp < b.timestamp end)

    local nextPost = {
        id = post.id,
        title = post.title or "",
        text = post.text or "",
        discordLink = post.discordLink or "",
        setAsGmotd = post.setAsGmotd == true,
        author = post.author or "Onbekend",
        createdAt = tonumber(post.createdAt) or time(),
        readBy = incomingReadBy,
        likedBy = incomingLikedBy,
        comments = mergedComments,
    }

    NS.db.posts[post.id] = nextPost

    if not existing then
        table.insert(NS.db.order, post.id)
    end

    sortPosts()

    local changed = (not existing)
        or existing.title ~= nextPost.title
        or existing.text ~= nextPost.text
        or (existing.discordLink or "") ~= nextPost.discordLink
        or (existing.setAsGmotd == true) ~= nextPost.setAsGmotd
        or existing.author ~= nextPost.author
        or existing.createdAt ~= nextPost.createdAt
        or serializeReadBy(existing.readBy) ~= serializeReadBy(nextPost.readBy)
        or serializeLikedBy(existing.likedBy) ~= serializeLikedBy(nextPost.likedBy)
        or serializeComments(existing.comments) ~= serializeComments(nextPost.comments)

    if mutateRevision and changed then
        touchRevision()
    end

    local retentionChanged = applyRetentionRules(mutateRevision)

    if NS.RefreshUI and (changed or retentionChanged) then
        NS.RefreshUI()
    end
end

local function markPostRead(postId, readerName, broadcast, refreshNow)
    if not postId or postId == "" then
        return false
    end

    local post = NS.db and NS.db.posts and NS.db.posts[postId]
    if not post then
        return false
    end

    post.readBy = sanitizeReadBy(post.readBy)

    local readerKey = getReaderKey(readerName)
    if readerKey == "" or post.readBy[readerKey] then
        return false
    end

    post.readBy[readerKey] = true

    if broadcast then
        sendCommand("GUILD", nil, "READ", joinPayload(postId))
    end

    if refreshNow ~= false and NS.RefreshUI then
        NS.RefreshUI()
    end

    return true
end

local function markPostComment(postId, author, commentId, timestamp, text, parentId, broadcast, refreshNow)
    if not postId or postId == "" then
        return false
    end

    local post = NS.db and NS.db.posts and NS.db.posts[postId]
    if not post then
        return false
    end

    local authorKey = getReaderKey(author)
    if authorKey == "" or not text or text == "" then
        return false
    end

    commentId = tostring(commentId or "")
    if commentId == "" then
        commentId = buildCommentId(author)
    end

    parentId = tostring(parentId or "")
    if parentId == commentId then
        parentId = ""
    end

    post.comments = sanitizeComments(post.comments)

    for _, c in ipairs(post.comments) do
        if c.id == commentId then
            return false
        end
    end

    if parentId ~= "" then
        local parentExists = false
        for _, c in ipairs(post.comments) do
            if c.id == parentId then
                parentExists = true
                break
            end
        end
        if not parentExists then
            parentId = ""
        end
    end

    table.insert(post.comments, {
        id = commentId,
        author = author,
        timestamp = tonumber(timestamp) or time(),
        text = text,
        parentId = parentId,
    })
    post.comments = sanitizeComments(post.comments)

    if broadcast then
        sendCommand("GUILD", nil, "COMMENT", joinPayload(postId, commentId, timestamp, text, parentId))
    end

    if refreshNow ~= false and NS.RefreshUI then
        NS.RefreshUI()
    end

    return true
end

local function localCanEditComment(comment)
    if type(comment) ~= "table" then
        return false
    end
    return namesMatch(comment.author, UnitName("player"))
end

local function senderCanEditComment(sender, comment)
    if type(comment) ~= "table" then
        return false
    end
    return namesMatch(comment.author, sender)
end

local function markPostCommentEdit(postId, commentId, editorName, text, broadcast, refreshNow)
    if not postId or postId == "" or not commentId or commentId == "" then
        return false
    end

    local post = NS.db and NS.db.posts and NS.db.posts[postId]
    if not post then
        return false
    end

    text = (text or ""):gsub("^%s+", ""):gsub("%s+$", "")
    if text == "" then
        return false
    end

    post.comments = sanitizeComments(post.comments)

    for i = 1, #post.comments do
        local c = post.comments[i]
        if c.id == commentId then
            if broadcast then
                if not localCanEditComment(c) then
                    return false
                end
            else
                if not senderCanEditComment(editorName, c) then
                    return false
                end
            end

            if c.text == text then
                return false
            end

            c.text = text

            if broadcast then
                sendCommand("GUILD", nil, "COMMENT_EDIT", joinPayload(postId, commentId, text))
            end

            if refreshNow ~= false and NS.RefreshUI then
                NS.RefreshUI()
            end

            return true
        end
    end

    return false
end

local function markPostCommentDelete(postId, commentId, actorName, broadcast, refreshNow)
    if not postId or postId == "" or not commentId or commentId == "" then
        return false
    end

    local post = NS.db and NS.db.posts and NS.db.posts[postId]
    if not post then
        return false
    end

    post.comments = sanitizeComments(post.comments)

    local canDelete = false
    if broadcast then
        canDelete = localCanDeletePost(postId)
    else
        canDelete = senderCanDeletePost(actorName, postId)
    end
    if not canDelete then
        return false
    end

    local removed = false
    local keep = {}
    local toDelete = { [commentId] = true }

    -- Delete selected comment and all descendant replies.
    local changed = true
    while changed do
        changed = false
        for _, c in ipairs(post.comments) do
            if c.parentId ~= "" and toDelete[c.parentId] and not toDelete[c.id] then
                toDelete[c.id] = true
                changed = true
            end
        end
    end

    for _, c in ipairs(post.comments) do
        if toDelete[c.id] then
            removed = true
        else
            table.insert(keep, c)
        end
    end

    if not removed then
        return false
    end

    post.comments = keep

    if broadcast then
        sendCommand("GUILD", nil, "COMMENT_DELETE", joinPayload(postId, commentId))
    end

    if refreshNow ~= false and NS.RefreshUI then
        NS.RefreshUI()
    end

    return true
end

local function markPostLike(postId, likerName, shouldLike, broadcast, refreshNow)
    if not postId or postId == "" then
        return false
    end

    local post = NS.db and NS.db.posts and NS.db.posts[postId]
    if not post then
        return false
    end

    post.likedBy = sanitizeLikedBy(post.likedBy)

    local likerKey = getReaderKey(likerName)
    if likerKey == "" then
        return false
    end

    local currentlyLiked = post.likedBy[likerKey] == true
    local desired = shouldLike
    if desired == nil then
        desired = true
    else
        desired = desired == true
    end

    if currentlyLiked == desired then
        return false
    end

    if desired then
        post.likedBy[likerKey] = true
    else
        post.likedBy[likerKey] = nil
    end

    if broadcast then
        sendCommand("GUILD", nil, "LIKE", joinPayload(postId, desired and "1" or "0"))
    end

    if refreshNow ~= false and NS.RefreshUI then
        NS.RefreshUI()
    end

    return true
end

local function removePost(postId, mutateRevision)
    if not postId or postId == "" then
        return
    end

    if not NS.db.posts[postId] then
        return
    end

    NS.db.posts[postId] = nil
    rebuildOrder()

    if mutateRevision then
        touchRevision()
    end

    if NS.RefreshUI then
        NS.RefreshUI()
    end
end

local function postToPayload(post)
    local gmotdFlag = post.setAsGmotd and "1" or "0"
    return joinPayload(
        post.id,
        post.createdAt,
        post.author,
        post.title,
        post.text,
        post.discordLink or "",
        gmotdFlag,
        serializeReadBy(post.readBy),
        serializeComments(post.comments)
    )
end

local function payloadToPost(payload)
    local fields = parsePayload(payload)
    return {
        id = fields[1],
        createdAt = tonumber(fields[2]) or time(),
        author = fields[3] or "Onbekend",
        title = fields[4] or "",
        text = fields[5] or "",
        discordLink = fields[6] or "",
        setAsGmotd = fields[7] == "1",
        readBy = sanitizeReadBy(fields[8] or ""),
        comments = deserializeComments(fields[9] or ""),
    }
end

local function maybeApplyGmotdFromPost(post, forceApply)
    if not post or not post.setAsGmotd then
        return false, "GMOTD_NOT_REQUESTED"
    end

    local postCreatedAt = tonumber(post.createdAt) or 0
    local lastCreatedAt = tonumber(NS.db.lastGmotdCreatedAt) or 0
    local lastPostId = NS.db.lastGmotdPostId

    if (not forceApply) and postCreatedAt < lastCreatedAt then
        return false, "GMOTD_OLDER_POST"
    end

    if (not forceApply) and postCreatedAt == lastCreatedAt and lastPostId and post.id <= lastPostId then
        return false, "GMOTD_OLDER_POST"
    end

    local motdText = buildGmotdText(post.title, post.text, post.discordLink)
    local ok, err = setGuildMotd(motdText)

    if not ok then
        return false, err
    end

    NS.db.lastGmotdPostId = post.id
    NS.db.lastGmotdCreatedAt = postCreatedAt

    return true, "GMOTD_SET"
end

local function getIdListCsv()
    return table.concat(NS.db.order, ",")
end

local function sendIndex(target)
    local payload = joinPayload(NS.db.revision or 1, getIdListCsv())
    sendCommand("WHISPER", target, "INDEX", payload)
end

local function sendAllowedPostRanks(target, distribution)
    if not isLocalGuildMaster() then
        return
    end

    local payload = joinPayload(
        getAllowedPostRanksCsv(),
        getDiscordHeaderUrl(),
        getAllowedDeleteRanksCsv(),
        getAllowedCommunicationViewRanksCsv()
    )
    sendCommand(distribution or "GUILD", target, "PERMS", payload)
end

local function sendPostById(target, postId)
    local post = NS.db.posts[postId]
    if not post then
        return
    end

    sendCommand("WHISPER", target, "POST", postToPayload(post))
end

local function requestMissingPosts(sender, remoteIdsCsv)
    local remoteSet = {}
    for id in string.gmatch(remoteIdsCsv or "", "[^,]+") do
        remoteSet[id] = true
    end

    local missing = {}
    for id, _ in pairs(remoteSet) do
        if not NS.db.posts[id] then
            table.insert(missing, id)
        end
    end

    table.sort(missing)
    for i, id in ipairs(missing) do
        local delay = (i - 1) * GET_REQUEST_INTERVAL_SECONDS
        C_Timer.After(delay, function()
            if NS.db and NS.db.posts and not NS.db.posts[id] then
                sendCommand("WHISPER", sender, "GET", joinPayload(id))
            end
        end)
    end
end

local function isAllowedCommandSource(command, distribution, sender)
    if not command or command == "" or not sender or sender == "" then
        return false
    end

    if isSamePlayer(sender) then
        return false
    end

    if not isKnownGuildSender(sender) then
        return false
    end

    if command == "HELLO" then
        return distribution == "GUILD" or distribution == "OFFICER"
    end

    if command == "INDEX" or command == "GET" or command == "POST" then
        return distribution == "WHISPER"
    end

    if command == "PERMS" then
        if distribution ~= "WHISPER" and distribution ~= "GUILD" and distribution ~= "OFFICER" then
            return false
        end
        return rankIsGuildMaster(getRosterEntry(sender))
    end

    if command == "NEWPOST" then
        if distribution ~= "GUILD" and distribution ~= "OFFICER" then
            return false
        end
        return senderCanManage(sender)
    end

    if command == "DELETE" then
        return distribution == "GUILD" or distribution == "OFFICER"
    end

    if command == "READ" then
        return distribution == "GUILD" or distribution == "OFFICER"
    end

    if command == "LIKE" then
        return distribution == "GUILD" or distribution == "OFFICER"
    end

    return false
end

local function processCommand(command, payload, sender, distribution)
    if not isAllowedCommandSource(command, distribution, sender) then
        return
    end

    if command == "HELLO" then
        sendIndex(sender)
        sendAllowedPostRanks(sender, "WHISPER")
        return
    end

    if command == "INDEX" then
        local fields = parsePayload(payload)
        local remoteIdsCsv = fields[2] or ""
        requestMissingPosts(sender, remoteIdsCsv)
        return
    end

    if command == "PERMS" then
        local fields = parsePayload(payload)
        NS.UpdateConfig({
            allowedPostRanks = fields[1] or "",
            discordHeaderUrl = fields[2],
            allowedDeleteRanks = fields[3] or "",
            allowedCommunicationViewRanks = fields[4] or "",
        })
        return
    end

    if command == "GET" then
        if not canServeGetRequest(sender) then
            return
        end

        local fields = parsePayload(payload)
        local requestedId = fields[1]
        if requestedId then
            sendPostById(sender, requestedId)
        end
        return
    end

    if command == "POST" then
        local post = payloadToPost(payload)
        upsertPost(post, false)
        maybeApplyGmotdFromPost(post)
        return
    end

    if command == "NEWPOST" then
        local post = payloadToPost(payload)
        upsertPost(post, true)
        maybeApplyGmotdFromPost(post)
        return
    end

    if command == "DELETE" then
        local fields = parsePayload(payload)
        local postId = fields[1]
        if postId and senderCanDeletePost(sender, postId) then
            removePost(postId, true)
        end
        return
    end

    if command == "READ" then
        local fields = parsePayload(payload)
        local postId = fields[1]
        if postId then
            markPostRead(postId, sender, false)
        end
        return
    end

    if command == "LIKE" then
        local fields = parsePayload(payload)
        local postId = fields[1]
        local shouldLike = fields[2] == "1"
        if postId then
            markPostLike(postId, sender, shouldLike, false)
        end
        return
    end

    if command == "COMMENT" then
        local fields = parsePayload(payload)
        local postId = fields[1]
        local commentId = fields[2] or ""
        local timestamp = tonumber(fields[3]) or time()
        local text = fields[4] or ""
        local parentId = fields[5] or ""
        if postId and text ~= "" then
            markPostComment(postId, sender, commentId, timestamp, text, parentId, false)
        end
        return
    end

    if command == "COMMENT_EDIT" then
        local fields = parsePayload(payload)
        local postId = fields[1]
        local commentId = fields[2]
        local text = fields[3] or ""
        if postId and commentId and text ~= "" then
            markPostCommentEdit(postId, commentId, sender, text, false)
        end
        return
    end

    if command == "COMMENT_DELETE" then
        local fields = parsePayload(payload)
        local postId = fields[1]
        local commentId = fields[2]
        if postId and commentId then
            markPostCommentDelete(postId, commentId, sender, false)
        end
        return
    end
end

local function processIncoming(prefix, message, distribution, sender)
    if prefix ~= PREFIX or not message or message == "" then
        return
    end

    local pieces = split(message, SEP)
    local msgType = pieces[1]

    if msgType == "MSG" then
        local raw = table.concat(pieces, SEP, 2)
        local code, guild, command, payload = parseWireRaw(raw)
        if not code or code ~= getCommunicationCode() then
            return
        end
        if not guild or guild ~= getGuildName() then
            return
        end
        markPeerActivity(sender)
        processCommand(command, payload, sender, distribution)
        return
    end

    if msgType == "CHUNK" then
        local msgId = pieces[2]
        local idx = tonumber(pieces[3])
        local total = tonumber(pieces[4])
        local part = table.concat(pieces, SEP, 5)

        if not msgId or not idx or not total then
            return
        end

        incomingChunks[msgId] = incomingChunks[msgId] or {
            total = total,
            parts = {},
            sender = sender,
            distribution = distribution,
            time = time(),
        }

        local chunk = incomingChunks[msgId]
        if chunk.sender ~= sender or chunk.distribution ~= distribution or chunk.total ~= total then
            incomingChunks[msgId] = nil
            return
        end

        chunk.parts[idx] = part

        local complete = true
        for i = 1, chunk.total do
            if not chunk.parts[i] then
                complete = false
                break
            end
        end

        if complete then
            local full = table.concat(chunk.parts, "")
            incomingChunks[msgId] = nil

            local code, guild, command, payload = parseWireRaw(full)
            if not code or code ~= getCommunicationCode() then
                return
            end
            if not guild or guild ~= getGuildName() then
                return
            end

            markPeerActivity(chunk.sender)
            processCommand(command, payload, chunk.sender, chunk.distribution)
        end
    end
end

local function purgeStaleChunks()
    local now = time()
    for id, info in pairs(incomingChunks) do
        if now - (info.time or now) > 60 then
            incomingChunks[id] = nil
        end
    end

    for senderKey, info in pairs(getRequestTracker) do
        if now - (info.lastSeen or now) > (GET_RATE_WINDOW_SECONDS * 6) then
            getRequestTracker[senderKey] = nil
        end
    end

    for senderKey, activity in pairs(peerActivityTracker) do
        local seenAt = nil
        if type(activity) == "table" then
            seenAt = activity.seenAt
        else
            seenAt = activity
        end

        if now - (seenAt or 0) > (PEER_ACTIVITY_WINDOW_SECONDS * 2) then
            peerActivityTracker[senderKey] = nil
        end
    end
end

local function sendHello()
    if not IsInGuild() then
        return
    end

    sendCommand("GUILD", nil, "HELLO", joinPayload(NS.db.revision or 1))
    sendAllowedPostRanks(nil, "GUILD")
end

function NS.CanManagePosts()
    return canManagePostsLocal()
end

function NS.CanViewCommunicationMembers()
    return canViewCommunicationMembersLocal()
end

function NS.IsLocalGuildMaster()
    return isLocalGuildMaster()
end

function NS.GetGuildRanks()
    local out = {}
    if not IsInGuild() then
        return out
    end

    local seen = {}
    local getNumGuildRanks = rawget(_G, "GetNumGuildRanks")
    local count = getNumGuildRanks and getNumGuildRanks() or 0
    for i = 1, count do
        local label = GuildControlGetRankName and GuildControlGetRankName(i) or nil
        local rankIndex = i - 1
        seen[rankIndex] = true
        table.insert(out, {
            rankIndex = rankIndex,
            label = label or ("Rang " .. tostring(rankIndex)),
        })
    end

    if #out == 0 then
        for _, entry in pairs(rosterCache) do
            local rankIndex = tonumber(entry.rankIndex)
            if rankIndex ~= nil and not seen[rankIndex] then
                seen[rankIndex] = true
                table.insert(out, {
                    rankIndex = rankIndex,
                    label = entry.rankLabel ~= "" and entry.rankLabel or ("Rang " .. tostring(rankIndex)),
                })
            end
        end
        table.sort(out, function(a, b)
            return (a.rankIndex or 99) < (b.rankIndex or 99)
        end)
    end

    return out
end

function NS.GetCommunicationStats()
    return {
        onlineGuildMembers = getOnlineGuildMemberCount(),
        communicatingClients = getCommunicatingClientCount(),
    }
end

function NS.GetCommunicatingMembers()
    return getCommunicatingMemberNames()
end

function NS.GetAllOnlineGuildMembers()
    return getAllOnlineGuildMembersData()
end

function NS.GetClassColoredMemberName(name)
    local text = tostring(name or "")
    if text == "" then
        return ""
    end

    local hex = getClassColorHexByName(text)
    if not hex then
        return text
    end

    return "|cff" .. hex .. text .. "|r"
end

function NS.GetPosts()
    local out = {}
    for _, id in ipairs(NS.db.order) do
        local post = NS.db.posts[id]
        if post then
            table.insert(out, post)
        end
    end
    return out
end

function NS.MarkPostsRead(postIds)
    if type(postIds) ~= "table" then
        return
    end

    local localPlayer = UnitName("player") or ""
    local changed = false
    for _, postId in ipairs(postIds) do
        if markPostRead(postId, localPlayer, true, false) then
            changed = true
        end
    end

    if changed and NS.RefreshUI then
        NS.RefreshUI()
    end
end

function NS.GetUniqueReadCount(post)
    if type(post) ~= "table" then
        return 0
    end

    return countMapEntries(sanitizeReadBy(post.readBy))
end

function NS.TogglePostLike(postId)
    local localPlayer = UnitName("player") or ""
    return markPostLike(postId, localPlayer, nil, true, true)
end

function NS.GetPostLikeCount(post)
    if type(post) ~= "table" then
        return 0
    end

    return countMapEntries(sanitizeLikedBy(post.likedBy))
end

function NS.GetPostLikeNames(post)
    if type(post) ~= "table" then
        return {}
    end

    local out = {}
    for likerKey, isLiked in pairs(sanitizeLikedBy(post.likedBy)) do
        if isLiked then
            table.insert(out, likerKey)
        end
    end

    table.sort(out)
    return out
end

function NS.GetPostReaderNames(post)
    if type(post) ~= "table" then
        return {}
    end

    local out = {}
    for readerKey, isRead in pairs(sanitizeReadBy(post.readBy)) do
        if isRead then
            table.insert(out, readerKey)
        end
    end

    table.sort(out)
    return out
end

function NS.GetPostComments(post)
    if type(post) ~= "table" then
        return {}
    end
    return sanitizeComments(post.comments)
end

function NS.CanEditComment(comment)
    return localCanEditComment(comment)
end

function NS.AddComment(postId, text, parentId)
    if not postId or postId == "" then
        return false, "NO_POST"
    end
    text = (text or ""):gsub("^%s+", ""):gsub("%s+$", "")
    if text == "" then
        return false, "TEXT_REQUIRED"
    end
    if #text > 200 then
        return false, "TEXT_TOO_LONG"
    end
    local localPlayer = UnitName("player") or ""
    if localPlayer == "" then
        return false, "NO_PLAYER"
    end

    local commentId = buildCommentId(localPlayer)
    local ts = time()
    local ok = markPostComment(postId, localPlayer, commentId, ts, text, parentId or "", true, true)
    if ok then
        return true, commentId
    end
    return false, "FAILED"
end

function NS.EditComment(postId, commentId, text)
    if not postId or postId == "" or not commentId or commentId == "" then
        return false, "NOT_FOUND"
    end
    text = (text or ""):gsub("^%s+", ""):gsub("%s+$", "")
    if text == "" then
        return false, "TEXT_REQUIRED"
    end
    if #text > 200 then
        return false, "TEXT_TOO_LONG"
    end
    local ok = markPostCommentEdit(postId, commentId, UnitName("player") or "", text, true, true)
    if ok then
        return true
    end
    return false, "NO_PERMISSION"
end

function NS.DeleteComment(postId, commentId)
    if not postId or postId == "" or not commentId or commentId == "" then
        return false, "NOT_FOUND"
    end
    local ok = markPostCommentDelete(postId, commentId, UnitName("player") or "", true, true)
    if ok then
        return true
    end
    return false, "NO_PERMISSION"
end

function NS.HasLocalPlayerLikedPost(post)
    if type(post) ~= "table" then
        return false
    end

    local likerKey = getReaderKey(UnitName("player") or "")
    if likerKey == "" then
        return false
    end

    local likedBy = sanitizeLikedBy(post.likedBy)
    return likedBy[likerKey] == true
end

function NS.GetConfig()
    local cfg = sanitizeConfig(NS.db and NS.db.config or DEFAULT_CONFIG)
    return {
        maxPosts = cfg.maxPosts,
        pruneDays = cfg.pruneDays,
        showLoginMessage = cfg.showLoginMessage,
        sortNewestFirst = cfg.sortNewestFirst,
        allowedPostRanks = copyNumberArray(cfg.allowedPostRanks),
        allowedDeleteRanks = copyNumberArray(cfg.allowedDeleteRanks),
        allowedCommunicationViewRanks = copyNumberArray(cfg.allowedCommunicationViewRanks),
        communicationCode = cfg.communicationCode,
        fontType = cfg.fontType,
        fontSize = cfg.fontSize,
        launcherPoint = cfg.launcherPoint,
        launcherRelativePoint = cfg.launcherRelativePoint,
        launcherX = cfg.launcherX,
        launcherY = cfg.launcherY,
        launcherScale = cfg.launcherScale,
        mainFrameWidth = cfg.mainFrameWidth,
        mainFrameHeight = cfg.mainFrameHeight,
        mainFrameScale = cfg.mainFrameScale,
        discordHeaderUrl = cfg.discordHeaderUrl,
    }
end

function NS.UpdateConfig(nextConfig)
    if not NS.db then
        ensureDb()
    end

    local old = NS.GetConfig()
    local merged = {
        maxPosts = old.maxPosts,
        pruneDays = old.pruneDays,
        showLoginMessage = old.showLoginMessage,
        sortNewestFirst = old.sortNewestFirst,
        allowedPostRanks = copyNumberArray(old.allowedPostRanks),
        allowedDeleteRanks = copyNumberArray(old.allowedDeleteRanks),
        allowedCommunicationViewRanks = copyNumberArray(old.allowedCommunicationViewRanks),
        communicationCode = old.communicationCode,
        fontType = old.fontType,
        fontSize = old.fontSize,
        launcherPoint = old.launcherPoint,
        launcherRelativePoint = old.launcherRelativePoint,
        launcherX = old.launcherX,
        launcherY = old.launcherY,
        launcherScale = old.launcherScale,
        mainFrameWidth = old.mainFrameWidth,
        mainFrameHeight = old.mainFrameHeight,
        mainFrameScale = old.mainFrameScale,
        discordHeaderUrl = old.discordHeaderUrl,
    }

    if nextConfig then
        for key, value in pairs(nextConfig) do
            merged[key] = value
        end
    end

    local newCfg = sanitizeConfig(merged)

    NS.db.config.maxPosts = newCfg.maxPosts
    NS.db.config.pruneDays = newCfg.pruneDays
    NS.db.config.showLoginMessage = newCfg.showLoginMessage
    NS.db.config.sortNewestFirst = newCfg.sortNewestFirst
    NS.db.config.allowedPostRanks = copyNumberArray(newCfg.allowedPostRanks)
    NS.db.config.allowedDeleteRanks = copyNumberArray(newCfg.allowedDeleteRanks)
    NS.db.config.allowedCommunicationViewRanks = copyNumberArray(newCfg.allowedCommunicationViewRanks)
    NS.db.config.communicationCode = newCfg.communicationCode
    NS.db.config.fontType = newCfg.fontType
    NS.db.config.fontSize = newCfg.fontSize
    NS.db.config.launcherPoint = newCfg.launcherPoint
    NS.db.config.launcherRelativePoint = newCfg.launcherRelativePoint
    NS.db.config.launcherX = newCfg.launcherX
    NS.db.config.launcherY = newCfg.launcherY
    NS.db.config.launcherScale = newCfg.launcherScale
    NS.db.config.mainFrameWidth = newCfg.mainFrameWidth
    NS.db.config.mainFrameHeight = newCfg.mainFrameHeight
    NS.db.config.mainFrameScale = newCfg.mainFrameScale
    NS.db.config.discordHeaderUrl = newCfg.discordHeaderUrl

    local changed = old.maxPosts ~= newCfg.maxPosts
        or old.pruneDays ~= newCfg.pruneDays
        or old.showLoginMessage ~= newCfg.showLoginMessage
        or old.sortNewestFirst ~= newCfg.sortNewestFirst
        or getAllowedPostRanksCsv() ~= table.concat(old.allowedPostRanks or {}, ",")
        or getAllowedDeleteRanksCsv() ~= table.concat(old.allowedDeleteRanks or {}, ",")
        or getAllowedCommunicationViewRanksCsv() ~= table.concat(old.allowedCommunicationViewRanks or {}, ",")
        or old.communicationCode ~= newCfg.communicationCode
        or old.fontType ~= newCfg.fontType
        or old.fontSize ~= newCfg.fontSize
        or old.launcherPoint ~= newCfg.launcherPoint
        or old.launcherRelativePoint ~= newCfg.launcherRelativePoint
        or old.launcherX ~= newCfg.launcherX
        or old.launcherY ~= newCfg.launcherY
        or old.launcherScale ~= newCfg.launcherScale
        or old.mainFrameWidth ~= newCfg.mainFrameWidth
        or old.mainFrameHeight ~= newCfg.mainFrameHeight
        or old.mainFrameScale ~= newCfg.mainFrameScale
        or old.discordHeaderUrl ~= newCfg.discordHeaderUrl

    local retentionChanged = applyRetentionRules(false)
    if (changed or retentionChanged) and NS.RefreshUI then
        NS.RefreshUI()
    end

    if changed and isLocalGuildMaster() and (
        table.concat(old.allowedPostRanks or {}, ",") ~= table.concat(newCfg.allowedPostRanks or {}, ",")
        or table.concat(old.allowedDeleteRanks or {}, ",") ~= table.concat(newCfg.allowedDeleteRanks or {}, ",")
        or table.concat(old.allowedCommunicationViewRanks or {}, ",") ~= table.concat(newCfg.allowedCommunicationViewRanks or {}, ",")
        or old.discordHeaderUrl ~= newCfg.discordHeaderUrl
    ) then
        sendAllowedPostRanks(nil, "GUILD")
    end

    if old.communicationCode ~= newCfg.communicationCode and IsInGuild() then
        sendHello()
        guildHelloSent = true
    end

    return NS.GetConfig()
end

buildGmotdText = function(title, text, discordLink)
    local motd = string.format("%s: %s", title or "", text or "")
    if discordLink and discordLink ~= "" then
        motd = motd .. " " .. discordLink
    end

    if #motd > 127 then
        motd = string.sub(motd, 1, 124) .. "..."
    end

    return motd
end

setGuildMotd = function(message)
    if not message or message == "" then
        return false, "GMOTD_UNAVAILABLE"
    end

    local canEditMotd = false
    if CanEditMOTD and CanEditMOTD() then
        canEditMotd = true
    elseif C_GuildInfo and C_GuildInfo.CanEditMOTD and C_GuildInfo.CanEditMOTD() then
        canEditMotd = true
    end

    if not canEditMotd then
        return false, "NO_PERMISSION"
    end

    local cleanMessage = tostring(message):gsub("\n", " "):gsub("\r", " ")

    local function trySet(fn, ...)
        if not fn then
            return false
        end

        local ok, result = pcall(fn, ...)
        if not ok then
            return false
        end

        if result == false then
            return false
        end

        return true
    end

    if C_GuildInfo and trySet(C_GuildInfo.SetMOTD, cleanMessage) then
        if C_GuildInfo.GuildRoster then
            C_GuildInfo.GuildRoster()
        end
        return true
    end

    local legacySetMotd = rawget(_G, "GuildSetMOTD")
    if trySet(legacySetMotd, cleanMessage) then
        if C_GuildInfo and C_GuildInfo.GuildRoster then
            C_GuildInfo.GuildRoster()
        end
        return true
    end

    if C_GuildInfo and trySet(C_GuildInfo.SetGuildMOTD, cleanMessage) then
        if C_GuildInfo.GuildRoster then
            C_GuildInfo.GuildRoster()
        end
        return true
    end

    local runMacroText = rawget(_G, "RunMacroText")
    if runMacroText then
        local macroCmd = "/gmotd " .. cleanMessage
        if trySet(runMacroText, macroCmd) then
            if C_GuildInfo and C_GuildInfo.GuildRoster then
                C_GuildInfo.GuildRoster()
            end
            return true
        end
    end

    return false, "GMOTD_FAILED"
end

function NS.CreateLocalPost(title, text, discordLink, setAsGmotd)
    if not canManagePostsLocal() then
        msg("Je hebt geen rechten om headlines te posten.")
        return false, "NO_PERMISSION"
    end

    title = (title or ""):gsub("^%s+", ""):gsub("%s+$", "")
    text = (text or ""):gsub("^%s+", ""):gsub("%s+$", "")
    discordLink = (discordLink or ""):gsub("^%s+", ""):gsub("%s+$", "")

    if title == "" then
        return false, "TITLE_REQUIRED"
    end

    if text == "" then
        return false, "TEXT_REQUIRED"
    end

    if #text > 1000 then
        return false, "TEXT_TOO_LONG"
    end

    if discordLink ~= "" then
        local lower = discordLink:lower()
        local isUrl = lower:match("^https?://") ~= nil
        local isDiscord = lower:find("discord%.gg", 1, false)
            or lower:find("discord%.com", 1, false)
            or lower:find("discordapp%.com", 1, false)

        if not isUrl or not isDiscord then
            return false, "DISCORD_LINK_INVALID"
        end
    end

    local post = {
        id = buildPostId(),
        title = title,
        text = text,
        discordLink = discordLink,
        setAsGmotd = setAsGmotd == true,
        author = UnitName("player") or "Onbekend",
        createdAt = time(),
        readBy = {
            [getReaderKey(UnitName("player") or "")] = true,
        },
        likedBy = {},
        comments = {},
    }

    upsertPost(post, true)
    sendCommand("GUILD", nil, "NEWPOST", postToPayload(post))

    if setAsGmotd then
        local motdOk, motdErr = maybeApplyGmotdFromPost(post, true)
        if not motdOk then
            return true, motdErr
        end
        return true, "GMOTD_SET"
    end

    return true
end

function NS.UpdateLocalPost(postId, title, text, discordLink, setAsGmotd)
    if not postId or postId == "" then
        return false, "NOT_FOUND"
    end

    local existing = NS.db and NS.db.posts and NS.db.posts[postId]
    if not existing then
        return false, "NOT_FOUND"
    end

    if not localCanDeletePost(postId) then
        msg("Alleen de guild master of de auteur mag deze headline wijzigen.")
        return false, "NO_PERMISSION"
    end

    title = (title or ""):gsub("^%s+", ""):gsub("%s+$", "")
    text = (text or ""):gsub("^%s+", ""):gsub("%s+$", "")
    discordLink = (discordLink or ""):gsub("^%s+", ""):gsub("%s+$", "")

    if title == "" then
        return false, "TITLE_REQUIRED"
    end

    if text == "" then
        return false, "TEXT_REQUIRED"
    end

    if #text > 1000 then
        return false, "TEXT_TOO_LONG"
    end

    if discordLink ~= "" then
        local lower = discordLink:lower()
        local isUrl = lower:match("^https?://") ~= nil
        local isDiscord = lower:find("discord%.gg", 1, false)
            or lower:find("discord%.com", 1, false)
            or lower:find("discordapp%.com", 1, false)

        if not isUrl or not isDiscord then
            return false, "DISCORD_LINK_INVALID"
        end
    end

    local post = {
        id = existing.id,
        title = title,
        text = text,
        discordLink = discordLink,
        setAsGmotd = setAsGmotd == true,
        author = existing.author or UnitName("player") or "Onbekend",
        createdAt = existing.createdAt or time(),
        readBy = sanitizeReadBy(existing.readBy),
        likedBy = sanitizeLikedBy(existing.likedBy),
        comments = sanitizeComments(existing.comments),
    }

    upsertPost(post, true)
    sendCommand("GUILD", nil, "NEWPOST", postToPayload(post))

    if setAsGmotd then
        local motdOk, motdErr = maybeApplyGmotdFromPost(post, true)
        if not motdOk then
            return true, motdErr
        end
        return true, "GMOTD_SET"
    end

    return true
end

function NS.DeleteLocalPost(postId)
    if not localCanDeletePost(postId) then
        msg("Alleen de guild master of de auteur mag deze headline verwijderen.")
        return false, "NO_PERMISSION"
    end

    if not NS.db.posts[postId] then
        return false, "NOT_FOUND"
    end

    removePost(postId, true)
    sendCommand("GUILD", nil, "DELETE", joinPayload(postId))
    return true
end

function NS.CanDeletePost(postId)
    if not postId or postId == "" or not NS.db or not NS.db.posts then
        return false
    end

    return localCanDeletePost(postId)
end

function NS.GenerateCommunicationCode()
    local alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
    local parts = {}

    for i = 1, 3 do
        local block = {}
        for j = 1, 4 do
            local index = math.random(1, #alphabet)
            table.insert(block, string.sub(alphabet, index, index))
        end
        table.insert(parts, table.concat(block, ""))
    end

    return string.lower(table.concat(parts, "-"))
end

function NS.FormatTimestamp(ts)
    local d = date("*t", ts)
    return string.format("%02d-%02d-%04d %02d:%02d", d.day, d.month, d.year, d.hour, d.min)
end

local function registerSlashCommands()
    SLASH_EERSTKOFFIEHEADLINES1 = "/ekh"
    SlashCmdList.EERSTKOFFIEHEADLINES = function(message)
        local arg = (message or ""):lower():gsub("^%s+", ""):gsub("%s+$", "")
        if arg == "config" or arg == "instellingen" then
            if NS.ToggleConfigFrame then
                NS.ToggleConfigFrame()
            end
            return
        end

        if NS.ToggleMainFrame then
            NS.ToggleMainFrame()
        end
    end
end

eventFrame:SetScript("OnEvent", function(_, event, ...)
    if event == "PLAYER_LOGIN" then
        ensureDb()
        applyRetentionRules(false)
        registerSlashCommands()
        if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then
            C_ChatInfo.RegisterAddonMessagePrefix(PREFIX)
        end

        if C_GuildInfo and C_GuildInfo.GuildRoster then
            C_GuildInfo.GuildRoster()
        end
        updateRosterCache()

        if NS.db.config.showLoginMessage then
            msg("Gebruik /ekh voor headlines en /ekh config voor instellingen.")
        end

        if NS.RefreshUI then
            NS.RefreshUI()
        end

        C_Timer.After(3, function()
            sendHello()
            guildHelloSent = true
        end)

        eventFrame:RegisterEvent("GUILD_ROSTER_UPDATE")
        eventFrame:RegisterEvent("CHAT_MSG_ADDON")
        eventFrame:SetScript("OnUpdate", function(_, elapsed)
            NS._chunkPurgeElapsed = (NS._chunkPurgeElapsed or 0) + elapsed
            if NS._chunkPurgeElapsed > 15 then
                NS._chunkPurgeElapsed = 0
                purgeStaleChunks()
            end

            NS._helloElapsed = (NS._helloElapsed or 0) + elapsed
            if NS._helloElapsed > HELLO_RESYNC_SECONDS then
                NS._helloElapsed = 0
                if IsInGuild() then
                    sendHello()
                end
            end
        end)
        return
    end

    if event == "GUILD_ROSTER_UPDATE" then
        updateRosterCache()
        if not guildHelloSent and IsInGuild() then
            sendHello()
            guildHelloSent = true
        end
        return
    end

    if event == "CHAT_MSG_ADDON" then
        processIncoming(...)
    end
end)

eventFrame:RegisterEvent("PLAYER_LOGIN")
