local addonName, RF = ...
local T

-------------------------------------------------------------------------------
-- Info Tab: MOTD, Guild Description, News Feed, Guild Stats, Challenges, Event Log
-------------------------------------------------------------------------------
-------------------------------------------------------------------------------
-- Scroll Indicator Factory
-------------------------------------------------------------------------------
local TRACK_WIDTH = 4
local THUMB_MIN_HEIGHT = 16

local function CreateMessageFrameScrollbar(bgFrame, msgFrame)
    local T = RF.Theme

    local track = CreateFrame("Frame", nil, bgFrame)
    track:SetWidth(TRACK_WIDTH)
    track:SetPoint("TOPRIGHT", bgFrame, "TOPRIGHT", -2, -2)
    track:SetPoint("BOTTOMRIGHT", bgFrame, "BOTTOMRIGHT", -2, 2)

    local trackTex = track:CreateTexture(nil, "BACKGROUND")
    trackTex:SetAllPoints()
    trackTex:SetColorTexture(0.15, 0.15, 0.18, 0.6)

    local thumb = CreateFrame("Frame", nil, track)
    thumb:SetWidth(TRACK_WIDTH)
    thumb:SetPoint("TOP", track, "TOP", 0, 0)
    thumb:SetHeight(THUMB_MIN_HEIGHT)

    local thumbTex = thumb:CreateTexture(nil, "ARTWORK")
    thumbTex:SetAllPoints()
    thumbTex:SetColorTexture(unpack(T.accent))

    track:Hide()

    local function UpdateScrollbar()
        local numMessages = msgFrame:GetNumMessages()
        local numVisible = msgFrame:GetNumVisibleLines() or 1
        if numMessages <= numVisible then
            track:Hide()
            return
        end
        track:Show()

        local trackHeight = track:GetHeight()
        local scrollRange = numMessages - numVisible
        local offset = msgFrame:GetScrollOffset()

        local thumbHeight = math.max(THUMB_MIN_HEIGHT, (numVisible / numMessages) * trackHeight)
        thumb:SetHeight(thumbHeight)

        local scrollable = trackHeight - thumbHeight
        local ratio = scrollRange > 0 and (offset / scrollRange) or 0
        -- offset 0 = bottom, so invert
        local yOffset = -(1 - ratio) * scrollable
        thumb:ClearAllPoints()
        thumb:SetPoint("TOP", track, "TOP", 0, yOffset)
    end

    -- Hook mouse wheel (runs after the existing handler)
    msgFrame:HookScript("OnMouseWheel", function()
        UpdateScrollbar()
    end)

    -- Hook AddMessage to update when content changes
    hooksecurefunc(msgFrame, "AddMessage", function()
        C_Timer.After(0, UpdateScrollbar)
    end)
    hooksecurefunc(msgFrame, "Clear", function()
        C_Timer.After(0, UpdateScrollbar)
    end)

    -- Shrink the message frame to make room for the track
    msgFrame:SetPoint("BOTTOMRIGHT", bgFrame, "BOTTOMRIGHT", -8, 6)

    return { Update = UpdateScrollbar }
end

local function CreateScrollFrameScrollbar(bgFrame, scrollFrame, scrollChild)
    local T = RF.Theme

    local track = CreateFrame("Frame", nil, bgFrame)
    track:SetWidth(TRACK_WIDTH)
    track:SetPoint("TOPRIGHT", bgFrame, "TOPRIGHT", -2, -2)
    track:SetPoint("BOTTOMRIGHT", bgFrame, "BOTTOMRIGHT", -2, 2)

    local trackTex = track:CreateTexture(nil, "BACKGROUND")
    trackTex:SetAllPoints()
    trackTex:SetColorTexture(0.15, 0.15, 0.18, 0.6)

    local thumb = CreateFrame("Frame", nil, track)
    thumb:SetWidth(TRACK_WIDTH)
    thumb:SetPoint("TOP", track, "TOP", 0, 0)
    thumb:SetHeight(THUMB_MIN_HEIGHT)

    local thumbTex = thumb:CreateTexture(nil, "ARTWORK")
    thumbTex:SetAllPoints()
    thumbTex:SetColorTexture(unpack(T.accent))

    track:Hide()

    local function UpdateScrollbar()
        local childHeight = scrollChild:GetHeight()
        local frameHeight = scrollFrame:GetHeight()
        if childHeight <= frameHeight or frameHeight <= 0 then
            track:Hide()
            return
        end
        track:Show()

        local trackHeight = track:GetHeight()
        local thumbHeight = math.max(THUMB_MIN_HEIGHT, (frameHeight / childHeight) * trackHeight)
        thumb:SetHeight(thumbHeight)

        local maxScroll = childHeight - frameHeight
        local curScroll = scrollFrame:GetVerticalScroll()
        local scrollable = trackHeight - thumbHeight
        local ratio = maxScroll > 0 and (curScroll / maxScroll) or 0
        local yOffset = -ratio * scrollable
        thumb:ClearAllPoints()
        thumb:SetPoint("TOP", track, "TOP", 0, yOffset)
    end

    scrollFrame:HookScript("OnMouseWheel", function()
        C_Timer.After(0, UpdateScrollbar)
    end)

    -- Shrink the scroll frame to make room
    scrollFrame:SetPoint("BOTTOMRIGHT", bgFrame, "BOTTOMRIGHT", -8, 2)

    return { Update = UpdateScrollbar }
end

-------------------------------------------------------------------------------
-- Ensure guild challenge API is available (lives in Blizzard_GuildUI, a LoD addon)
-------------------------------------------------------------------------------
local function EnsureChallengeAPI()
    if GetGuildChallengeInfo then return true end
    pcall(function()
        if C_AddOns and C_AddOns.LoadAddOn then
            C_AddOns.LoadAddOn("Blizzard_GuildUI")
        elseif LoadAddOn then
            LoadAddOn("Blizzard_GuildUI")
        end
    end)
    return GetGuildChallengeInfo ~= nil
end

-------------------------------------------------------------------------------
-- Init Info Tab
-------------------------------------------------------------------------------
function RF:InitInfoTab(parent)
    T = RF.Theme

    -- Left column (MOTD + Description + News) | Right column (Stats + Challenges + Event Log)
    local leftCol = CreateFrame("Frame", nil, parent)
    leftCol:SetPoint("TOPLEFT", 8, -8)
    leftCol:SetPoint("BOTTOMLEFT", 8, 8)
    leftCol:SetWidth(560)

    local rightCol = CreateFrame("Frame", nil, parent)
    rightCol:SetPoint("TOPLEFT", leftCol, "TOPRIGHT", 12, 0)
    rightCol:SetPoint("BOTTOMRIGHT", -8, 8)

    ---------------------------------------------------------------------------
    -- MOTD Section (Left Column)
    ---------------------------------------------------------------------------
    local motdHeader = RF:CreateLabel(leftCol, "Message of the Day", RF.FONT_SIZES.medium, "textPrimary")
    motdHeader:SetPoint("TOPLEFT", 0, 0)

    local motdBg = CreateFrame("Frame", nil, leftCol, "BackdropTemplate")
    motdBg:SetPoint("TOPLEFT", motdHeader, "BOTTOMLEFT", 0, -4)
    motdBg:SetPoint("RIGHT", leftCol, "RIGHT", 0, 0)
    motdBg:SetHeight(50)
    motdBg:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    motdBg:SetBackdropColor(unpack(T.bgTertiary))
    motdBg:SetBackdropBorderColor(unpack(T.border))

    -- Clickable overlay for URL detection
    local motdBtn = CreateFrame("Button", nil, motdBg)
    motdBtn:SetAllPoints()
    motdBtn:SetScript("OnClick", function()
        local raw = parent._motdRaw or ""
        local url = RF:ExtractURL(raw)
        if url then
            RF:ShowCopyPopup(url)
        end
    end)

    local motdText = motdBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    motdText:SetFont(motdText:GetFont(), RF.FONT_SIZES.normal, "")
    motdText:SetPoint("TOPLEFT", 8, -8)
    motdText:SetPoint("BOTTOMRIGHT", -8, 8)
    motdText:SetJustifyH("LEFT")
    motdText:SetJustifyV("TOP")
    motdText:SetTextColor(unpack(T.textPrimary))
    motdText:SetWordWrap(true)
    parent.motdText = motdText

    -- Edit MOTD button
    local editMotdBtn = RF:CreateButton(leftCol, "Edit MOTD", 80, 22)
    editMotdBtn:SetPoint("TOPRIGHT", motdBg, "BOTTOMRIGHT", 0, -4)
    editMotdBtn:SetScript("OnClick", function()
        RF:ShowMotdEditor()
    end)

    ---------------------------------------------------------------------------
    -- Guild Description Section (Left Column, between MOTD and News)
    ---------------------------------------------------------------------------
    local descHeader = RF:CreateLabel(leftCol, "Guild Description", RF.FONT_SIZES.medium, "textPrimary")
    descHeader:SetPoint("TOPLEFT", motdBg, "BOTTOMLEFT", 0, -32)

    local descBg = CreateFrame("Frame", nil, leftCol, "BackdropTemplate")
    descBg:SetPoint("TOPLEFT", descHeader, "BOTTOMLEFT", 0, -4)
    descBg:SetPoint("RIGHT", leftCol, "RIGHT", 0, 0)
    descBg:SetHeight(80)
    descBg:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    descBg:SetBackdropColor(unpack(T.bgTertiary))
    descBg:SetBackdropBorderColor(unpack(T.border))

    -- Scroll frame for the description EditBox
    local descScroll = CreateFrame("ScrollFrame", nil, descBg, "UIPanelScrollFrameTemplate")
    descScroll:SetPoint("TOPLEFT", 6, -6)
    descScroll:SetPoint("BOTTOMRIGHT", -22, 6)

    local descEB = CreateFrame("EditBox", nil, descScroll)
    descEB:SetWidth(340)
    local fontPath = ChatFontNormal:GetFont()
    descEB:SetFont(fontPath, RF.FONT_SIZES.normal, "")
    descEB:SetTextColor(unpack(T.textPrimary))
    descEB:SetMultiLine(true)
    descEB:SetAutoFocus(false)
    descEB:SetMaxLetters(499)
    descEB:SetEnabled(false)
    descScroll:SetScrollChild(descEB)
    parent.descEB = descEB
    parent.descScroll = descScroll

    -- Keep EditBox width in sync with the scroll frame
    descScroll:SetScript("OnSizeChanged", function(self, w)
        descEB:SetWidth(w)
    end)

    -- Character counter
    local charCount = descBg:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    charCount:SetFont(fontPath, RF.FONT_SIZES.small, "")
    charCount:SetPoint("BOTTOMRIGHT", descBg, "BOTTOMRIGHT", -4, -14)
    charCount:SetTextColor(unpack(T.textMuted))
    charCount:SetText("0 / 499")
    charCount:Hide()
    parent.descCharCount = charCount

    descEB:SetScript("OnTextChanged", function(self, userInput)
        if userInput then
            local len = #self:GetText()
            charCount:SetText(len .. " / 499")
        end
    end)
    descEB:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
    end)

    -- URL click overlay for description (visible only in read-only mode)
    local descUrlBtn = CreateFrame("Button", nil, descBg)
    descUrlBtn:SetAllPoints()
    descUrlBtn:SetFrameLevel(descScroll:GetFrameLevel() + 10)

    -- Display text with highlighted URLs over the EditBox
    local descOverlayText = descUrlBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    descOverlayText:SetFont(fontPath, RF.FONT_SIZES.normal, "")
    descOverlayText:SetPoint("TOPLEFT", 6, -6)
    descOverlayText:SetPoint("BOTTOMRIGHT", -22, 6)
    descOverlayText:SetJustifyH("LEFT")
    descOverlayText:SetJustifyV("TOP")
    descOverlayText:SetTextColor(unpack(T.textPrimary))
    descOverlayText:SetWordWrap(true)
    parent.descOverlayText = descOverlayText

    descUrlBtn:SetScript("OnClick", function()
        local raw = parent._descRaw or ""
        local url = RF:ExtractURL(raw)
        if url then
            RF:ShowCopyPopup(url)
        end
    end)
    descUrlBtn:EnableMouseWheel(true)
    descUrlBtn:SetScript("OnMouseWheel", function(self, delta)
        local cur = descScroll:GetVerticalScroll()
        local max = descScroll:GetVerticalScrollRange()
        local step = 20
        descScroll:SetVerticalScroll(math.max(0, math.min(max, cur - delta * step)))
    end)
    parent.descUrlBtn = descUrlBtn

    -- Edit / Save buttons for description
    local descEditBtn = RF:CreateButton(leftCol, "Edit", 55, 22)
    descEditBtn:SetPoint("TOPRIGHT", descBg, "BOTTOMRIGHT", 0, -2)
    parent.descEditBtn = descEditBtn

    local descSaveBtn = RF:CreateButton(leftCol, "Copy", 55, 22, "accent")
    descSaveBtn:SetPoint("RIGHT", descEditBtn, "LEFT", -4, 0)
    descSaveBtn:Hide()
    parent.descSaveBtn = descSaveBtn

    local descCancelBtn = RF:CreateButton(leftCol, "Cancel", 55, 22)
    descCancelBtn:SetPoint("RIGHT", descSaveBtn, "LEFT", -4, 0)
    descCancelBtn:Hide()
    parent.descCancelBtn = descCancelBtn

    descEditBtn:SetScript("OnClick", function()
        local canEdit = CanEditGuildInfo and CanEditGuildInfo()
        if not canEdit then
            RF:Print("You don't have permission to edit guild info.")
            return
        end
        descScroll:Show()
        descEB:SetEnabled(true)
        descEB:SetFocus()
        descUrlBtn:Hide()
        descOverlayText:Hide()
        descBg:SetBackdropBorderColor(unpack(T.accent))
        charCount:SetText(#descEB:GetText() .. " / 499")
        charCount:Show()
        descSaveBtn:Show()
        descCancelBtn:Show()
        descEditBtn:Hide()
    end)

    descSaveBtn:SetScript("OnClick", function()
        local text = descEB:GetText()
        -- SetGuildInfoText is fully protected in 12.0; copy text for user to paste
        RF:ShowMacroCopy(text, "Guild description copied. Paste into: Guild Panel (J) > Info > Edit.")
        descEB:SetEnabled(false)
        descEB:ClearFocus()
        descScroll:Hide()
        descOverlayText:SetText(RF:ColorURLs(text))
        descOverlayText:Show()
        descUrlBtn:Show()
        descBg:SetBackdropBorderColor(unpack(T.border))
        charCount:Hide()
        descSaveBtn:Hide()
        descCancelBtn:Hide()
        descEditBtn:Show()
    end)

    descCancelBtn:SetScript("OnClick", function()
        -- Revert to current live description
        local desc = GetGuildInfoText and GetGuildInfoText() or ""
        descEB:SetText(desc)
        descEB:SetEnabled(false)
        descEB:ClearFocus()
        descScroll:Hide()
        descOverlayText:SetText(RF:ColorURLs(desc))
        descOverlayText:Show()
        descUrlBtn:Show()
        descBg:SetBackdropBorderColor(unpack(T.border))
        charCount:Hide()
        descSaveBtn:Hide()
        descCancelBtn:Hide()
        descEditBtn:Show()
    end)

    ---------------------------------------------------------------------------
    -- News Feed (Left Column, below description)
    ---------------------------------------------------------------------------
    local newsHeader = RF:CreateLabel(leftCol, "Guild News", RF.FONT_SIZES.medium, "textPrimary")
    newsHeader:SetPoint("TOPLEFT", descBg, "BOTTOMLEFT", 0, -28)

    -- News filter pills
    local newsFilterBar = CreateFrame("Frame", nil, leftCol)
    newsFilterBar:SetHeight(24)
    newsFilterBar:SetPoint("TOPLEFT", newsHeader, "BOTTOMLEFT", 0, -4)
    newsFilterBar:SetPoint("RIGHT", leftCol, "RIGHT", 0, 0)

    local newsFilters = { "All", "Achievements", "Loot", "Bosses" }
    local activeNewsFilter = "All"
    local newsPills = {}

    local xOff = 0
    for _, filterName in ipairs(newsFilters) do
        local pill = RF:CreatePillButton(newsFilterBar, filterName)
        pill:SetPoint("LEFT", xOff, 0)
        pill:SetActive(filterName == "All")
        xOff = xOff + pill:GetWidth() + 4

        pill:SetScript("OnClick", function()
            activeNewsFilter = filterName
            for _, p in ipairs(newsPills) do
                p:SetActive(p.label:GetText() == filterName)
            end
            RF:RefreshNewsFeed(parent)
        end)
        tinsert(newsPills, pill)
    end

    -- News container with backdrop
    local newsBg = CreateFrame("Frame", nil, leftCol, "BackdropTemplate")
    newsBg:SetPoint("TOPLEFT", newsFilterBar, "BOTTOMLEFT", 0, -4)
    newsBg:SetPoint("BOTTOMRIGHT", leftCol, "BOTTOMRIGHT", 0, 0)
    newsBg:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    newsBg:SetBackdropColor(unpack(T.bgTertiary))
    newsBg:SetBackdropBorderColor(unpack(T.border))

    -- News display: ScrollingMessageFrame for native hyperlink support
    local newsFrame = CreateFrame("ScrollingMessageFrame", nil, newsBg)
    newsFrame:SetPoint("TOPLEFT", 6, -6)
    newsFrame:SetPoint("BOTTOMRIGHT", -6, 6)
    newsFrame:SetFont(fontPath, RF.FONT_SIZES.normal, "")
    newsFrame:SetSpacing(4)
    newsFrame:SetTextColor(unpack(T.textSecondary))
    newsFrame:SetJustifyH("LEFT")
    newsFrame:SetMaxLines(200)
    newsFrame:SetFading(false)
    newsFrame:EnableMouseWheel(true)
    newsFrame:SetScript("OnMouseWheel", function(self, delta)
        if delta > 0 then self:ScrollUp() else self:ScrollDown() end
    end)
    newsFrame:SetHyperlinksEnabled(true)

    newsFrame:SetScript("OnHyperlinkEnter", function(self, link)
        if link:match("^url:") then return end
        GameTooltip:SetOwner(self, "ANCHOR_CURSOR")
        GameTooltip:SetHyperlink(link)
        GameTooltip:Show()
    end)
    newsFrame:SetScript("OnHyperlinkLeave", function()
        GameTooltip:Hide()
    end)

    newsFrame:SetScript("OnHyperlinkClick", function(self, link, text, button)
        local linkType, linkData = link:match("^(%a+):(.+)")
        if linkType == "url" then
            RF:ShowCopyPopup(linkData)
        elseif button == "RightButton" then
            local fullLink = "|H" .. link .. "|h" .. text .. "|h"
            if ChatEdit_InsertLink then
                ChatEdit_InsertLink(fullLink)
            end
        elseif ChatFrame_OnHyperlinkShow then
            ChatFrame_OnHyperlinkShow(ChatFrame1, link, text, button)
        end
    end)

    parent.newsScrollbar = CreateMessageFrameScrollbar(newsBg, newsFrame)
    parent.newsFrame = newsFrame
    parent.activeNewsFilter = function() return activeNewsFilter end

    ---------------------------------------------------------------------------
    -- Guild Statistics (Right Column)
    ---------------------------------------------------------------------------
    local statsHeader = RF:CreateLabel(rightCol, "Guild Statistics", RF.FONT_SIZES.medium, "textPrimary")
    statsHeader:SetPoint("TOPLEFT", 0, 0)

    local statsBorder = CreateFrame("Frame", nil, rightCol, "BackdropTemplate")
    statsBorder:SetPoint("TOPLEFT", statsHeader, "BOTTOMLEFT", 0, -4)
    statsBorder:SetPoint("RIGHT", rightCol, "RIGHT", 0, 0)
    statsBorder:SetHeight(160)
    statsBorder:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    statsBorder:SetBackdropColor(unpack(T.bgTertiary))
    statsBorder:SetBackdropBorderColor(unpack(T.border))

    local statsScroll = CreateFrame("ScrollFrame", nil, statsBorder)
    statsScroll:SetPoint("TOPLEFT", 0, 0)
    statsScroll:SetPoint("BOTTOMRIGHT", 0, 0)

    local statsBg = CreateFrame("Frame", nil, statsScroll)
    statsBg:SetWidth(statsBorder:GetWidth() or 200)
    statsBg:SetHeight(1) -- will be resized dynamically
    statsScroll:SetScrollChild(statsBg)
    statsScroll:EnableMouseWheel(true)
    statsScroll:SetScript("OnMouseWheel", function(self, delta)
        local cur = self:GetVerticalScroll()
        local maxScroll = math.max(0, statsBg:GetHeight() - self:GetHeight())
        local newScroll = math.max(0, math.min(cur - delta * 20, maxScroll))
        self:SetVerticalScroll(newScroll)
    end)

    -- Update scroll child width when border resizes
    statsBorder:SetScript("OnSizeChanged", function(self, w)
        statsBg:SetWidth(w)
    end)

    parent.statsScrollbar = CreateScrollFrameScrollbar(statsBorder, statsScroll, statsBg)
    parent.statsBg = statsBg
    parent.statsBorder = statsBorder
    parent.statsScroll = statsScroll
    parent.statsLabels = {}

    ---------------------------------------------------------------------------
    -- Guild Challenges (Right Column, below Stats)
    ---------------------------------------------------------------------------
    local challengeHeader = RF:CreateLabel(rightCol, "Guild Challenges", RF.FONT_SIZES.medium, "textPrimary")
    challengeHeader:SetPoint("TOPLEFT", statsBorder, "BOTTOMLEFT", 0, -8)

    local challengesBg = CreateFrame("Frame", nil, rightCol, "BackdropTemplate")
    challengesBg:SetPoint("TOPLEFT", challengeHeader, "BOTTOMLEFT", 0, -4)
    challengesBg:SetPoint("RIGHT", rightCol, "RIGHT", 0, 0)
    challengesBg:SetHeight(105)
    challengesBg:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    challengesBg:SetBackdropColor(unpack(T.bgTertiary))
    challengesBg:SetBackdropBorderColor(unpack(T.border))

    -- Create 4 challenge progress bars
    parent.challengeBars = {}
    local cy = -8
    for _, typeIndex in ipairs(RF.GUILD_CHALLENGE_ORDER) do
        local name = RF.GUILD_CHALLENGE_NAMES[typeIndex] or "Unknown"
        local barColor = RF.GUILD_CHALLENGE_COLORS[typeIndex] or { 0.5, 0.5, 0.5 }

        local label = challengesBg:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        label:SetFont(fontPath, RF.FONT_SIZES.small, "")
        label:SetPoint("TOPLEFT", 8, cy)
        label:SetWidth(70)
        label:SetJustifyH("LEFT")
        label:SetTextColor(unpack(barColor))
        label:SetText(name)

        local barFrame = CreateFrame("Frame", nil, challengesBg)
        barFrame:SetHeight(14)
        barFrame:SetPoint("LEFT", 82, 0)
        barFrame:SetPoint("RIGHT", -50, 0)
        barFrame:SetPoint("TOP", 0, cy - 1)

        local barBgTex = barFrame:CreateTexture(nil, "BACKGROUND")
        barBgTex:SetAllPoints()
        barBgTex:SetColorTexture(0.1, 0.1, 0.12, 1)

        local barFill = barFrame:CreateTexture(nil, "ARTWORK")
        barFill:SetPoint("TOPLEFT")
        barFill:SetPoint("BOTTOMLEFT")
        barFill:SetWidth(1)
        barFill:SetColorTexture(barColor[1], barColor[2], barColor[3], 0.7)

        local countText = challengesBg:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        countText:SetFont(fontPath, RF.FONT_SIZES.small, "")
        countText:SetPoint("RIGHT", -8, 0)
        countText:SetPoint("TOP", 0, cy - 1)
        countText:SetTextColor(unpack(T.textSecondary))
        countText:SetText("0/0")

        parent.challengeBars[typeIndex] = {
            barFrame = barFrame,
            barFill = barFill,
            countText = countText,
        }

        cy = cy - 24
    end

    ---------------------------------------------------------------------------
    -- Upcoming Events / Calendar (Right Column, below Challenges)
    ---------------------------------------------------------------------------
    local calendarHeader = RF:CreateLabel(rightCol, "Upcoming Events", RF.FONT_SIZES.medium, "textPrimary")
    calendarHeader:SetPoint("TOPLEFT", challengesBg, "BOTTOMLEFT", 0, -8)

    local calendarBg = CreateFrame("Frame", nil, rightCol, "BackdropTemplate")
    calendarBg:SetPoint("TOPLEFT", calendarHeader, "BOTTOMLEFT", 0, -4)
    calendarBg:SetPoint("RIGHT", rightCol, "RIGHT", 0, 0)
    calendarBg:SetHeight(90)
    calendarBg:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    calendarBg:SetBackdropColor(unpack(T.bgTertiary))
    calendarBg:SetBackdropBorderColor(unpack(T.border))

    local calendarFrame = CreateFrame("ScrollingMessageFrame", nil, calendarBg)
    calendarFrame:SetPoint("TOPLEFT", 6, -6)
    calendarFrame:SetPoint("BOTTOMRIGHT", -6, 6)
    calendarFrame:SetFont(fontPath, RF.FONT_SIZES.normal, "")
    calendarFrame:SetSpacing(3)
    calendarFrame:SetTextColor(unpack(T.textSecondary))
    calendarFrame:SetJustifyH("LEFT")
    calendarFrame:SetMaxLines(20)
    calendarFrame:SetFading(false)
    calendarFrame:EnableMouseWheel(true)
    calendarFrame:SetScript("OnMouseWheel", function(self, delta)
        if delta > 0 then self:ScrollUp() else self:ScrollDown() end
    end)
    calendarFrame:SetHyperlinksEnabled(true)
    calendarFrame:SetScript("OnHyperlinkClick", function(self, link)
        local linkType, linkData = link:match("^(%a+):(.+)")
        if linkType == "calevent" then
            local idx = tonumber(linkData)
            local ev = parent._calendarEvents and parent._calendarEvents[idx]
            if ev and C_Calendar.OpenEvent then
                C_Calendar.OpenEvent(ev.monthOffset, ev.day, ev.dayIndex)
                if CalendarFrame and CalendarFrame.Show then
                    CalendarFrame:Show()
                elseif ToggleCalendar then
                    ToggleCalendar()
                end
            end
        end
    end)
    calendarFrame:SetScript("OnHyperlinkEnter", function(self, link)
        if link:match("^calevent:") then
            SetCursor("CAST_CURSOR")
        end
    end)
    calendarFrame:SetScript("OnHyperlinkLeave", function()
        SetCursor(nil)
    end)
    parent.calendarScrollbar = CreateMessageFrameScrollbar(calendarBg, calendarFrame)
    parent.calendarFrame = calendarFrame

    ---------------------------------------------------------------------------
    -- Event Log (Right Column, below Calendar)
    ---------------------------------------------------------------------------
    local eventLogHeader = RF:CreateLabel(rightCol, "Event Log", RF.FONT_SIZES.medium, "textPrimary")
    eventLogHeader:SetPoint("TOPLEFT", calendarBg, "BOTTOMLEFT", 0, -8)

    local eventLogBg = CreateFrame("Frame", nil, rightCol, "BackdropTemplate")
    eventLogBg:SetPoint("TOPLEFT", eventLogHeader, "BOTTOMLEFT", 0, -4)
    eventLogBg:SetPoint("RIGHT", rightCol, "RIGHT", 0, 0)
    eventLogBg:SetPoint("BOTTOM", rightCol, "BOTTOM", 0, 0)
    eventLogBg:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    eventLogBg:SetBackdropColor(unpack(T.bgTertiary))
    eventLogBg:SetBackdropBorderColor(unpack(T.border))

    local eventLogFrame = CreateFrame("ScrollingMessageFrame", nil, eventLogBg)
    eventLogFrame:SetPoint("TOPLEFT", 6, -6)
    eventLogFrame:SetPoint("BOTTOMRIGHT", -6, 6)
    eventLogFrame:SetFont(fontPath, RF.FONT_SIZES.normal, "")
    eventLogFrame:SetSpacing(3)
    eventLogFrame:SetTextColor(unpack(T.textSecondary))
    eventLogFrame:SetJustifyH("LEFT")
    eventLogFrame:SetMaxLines(RF.MAX_EVENT_LOG_ENTRIES)
    eventLogFrame:SetFading(false)
    eventLogFrame:EnableMouseWheel(true)
    eventLogFrame:SetScript("OnMouseWheel", function(self, delta)
        if delta > 0 then self:ScrollUp() else self:ScrollDown() end
    end)
    eventLogFrame:SetHyperlinksEnabled(true)
    eventLogFrame:SetScript("OnHyperlinkClick", function(self, link)
        local linkType, linkData = link:match("^(%a+):(.+)")
        if linkType == "url" then
            RF:ShowCopyPopup(linkData)
        end
    end)

    parent.eventLogScrollbar = CreateMessageFrameScrollbar(eventLogBg, eventLogFrame)
    parent.eventLogFrame = eventLogFrame

    ---------------------------------------------------------------------------
    -- Refresh Functions
    ---------------------------------------------------------------------------
    function RF:RefreshInfoTab()
        if not parent:IsShown() then return end

        -- Update MOTD
        local motd = GetGuildRosterMOTD and GetGuildRosterMOTD() or ""
        parent._motdRaw = motd
        parent.motdText:SetText(motd ~= "" and RF:ColorURLs(motd) or "(No MOTD set)")

        -- Update guild description
        local desc = GetGuildInfoText and GetGuildInfoText() or ""
        parent._descRaw = desc
        parent.descEB:SetText(desc)
        parent.descOverlayText:SetText(RF:ColorURLs(desc))
        parent.descCharCount:SetText(#desc .. " / 499")
        -- Read-only mode: hide EditBox scroll, show overlay
        parent.descScroll:Hide()
        parent.descUrlBtn:Show()
        parent.descOverlayText:Show()

        -- Refresh all sections
        RF:RefreshNewsFeed(parent)
        RF:RefreshGuildStats(parent)
        RF:RefreshGuildChallenges(parent)
        RF:RefreshCalendarEvents(parent)
        RF:RefreshEventLog(parent)
    end

    -- Hook events
    RF:RegisterCallback("GUILD_MOTD", function()
        if parent:IsShown() then
            local motd = GetGuildRosterMOTD and GetGuildRosterMOTD() or ""
            parent._motdRaw = motd
            parent.motdText:SetText(motd ~= "" and RF:ColorURLs(motd) or "(No MOTD set)")
        end
    end)

    RF:RegisterCallback("GUILD_NEWS_UPDATE", function()
        if parent:IsShown() then
            RF:RefreshNewsFeed(parent)
        end
    end)

    RF:RegisterCallback("ROSTER_REFRESHED", function()
        if parent:IsShown() then
            RF:RefreshGuildStats(parent)
        end
    end)

    RF:RegisterCallback("GUILD_EVENT_LOG_UPDATE", function()
        if parent:IsShown() then
            RF:RefreshEventLog(parent)
        end
    end)

    -- Always refresh challenges when the server responds, even if the
    -- tab is hidden -- GetGuildChallengeInfo reads from the client cache
    -- that was just populated, so the next RefreshInfoTab will be correct.
    RF:RegisterCallback("GUILD_CHALLENGE_UPDATED", function()
        RF:RefreshGuildChallenges(parent)
    end)

    RF:RegisterCallback("GUILD_CHALLENGE_COMPLETED", function()
        RF:RefreshGuildChallenges(parent)
    end)

    RF:RegisterCallback("SWITCH_TAB", function(_, key)
        if key == "Info" then
            -- Request fresh data from server
            if C_GuildInfo and C_GuildInfo.QueryGuildNews then
                C_GuildInfo.QueryGuildNews()
            elseif QueryGuildNews then
                QueryGuildNews()
            end
            if QueryGuildEventLog then
                QueryGuildEventLog()
            end
            EnsureChallengeAPI()
            if RequestGuildChallengeInfo then
                RequestGuildChallengeInfo()
            end
            RF:RefreshInfoTab()
            -- Server responses are async; stagger follow-up refreshes so
            -- that even slow responses are captured.
            C_Timer.After(1, function()
                if parent:IsShown() then
                    RF:RefreshGuildChallenges(parent)
                    RF:RefreshEventLog(parent)
                end
            end)
            C_Timer.After(3, function()
                if parent:IsShown() then
                    RF:RefreshGuildChallenges(parent)
                end
            end)
        end
    end)

    -- Initial load
    RF:RefreshInfoTab()
end

-------------------------------------------------------------------------------
-- MOTD Editor
-------------------------------------------------------------------------------
function RF:ShowMotdEditor()
    if not RF.motdEditorFrame then
        local f = RF:CreateStyledFrame("RosterForgeMotdEditor", UIParent, 400, 120, {
            title = "Edit MOTD",
            closable = true,
            strata = "DIALOG",
        })
        f:SetPoint("CENTER")
        f:Hide()

        local eb = CreateFrame("EditBox", nil, f, "BackdropTemplate")
        eb:SetPoint("TOPLEFT", f.titleBar, "BOTTOMLEFT", 8, -8)
        eb:SetPoint("RIGHT", -8, 0)
        eb:SetHeight(40)
        eb:SetFontObject("ChatFontNormal")
        eb:SetAutoFocus(false)
        eb:SetMaxLetters(128)
        eb:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Buttons\\WHITE8x8",
            edgeSize = 1,
        })
        eb:SetBackdropColor(0.1, 0.1, 0.12, 1)
        eb:SetBackdropBorderColor(0.25, 0.25, 0.28, 1)
        f.editBox = eb

        local saveBtn = CreateFrame("Button", "RosterForgeMOTDSaveBtn", f, "SecureActionButtonTemplate")
        saveBtn:SetSize(60, 24)
        saveBtn:SetPoint("BOTTOMRIGHT", -8, 8)
        saveBtn:RegisterForClicks("AnyUp", "AnyDown")
        RF:SkinButton(saveBtn, "accent")
        local saveLabel = saveBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        saveLabel:SetPoint("CENTER")
        saveLabel:SetFont(saveLabel:GetFont(), RF.FONT_SIZES.normal, "")
        saveLabel:SetText("Save")
        saveLabel:SetTextColor(unpack(RF.Theme.textPrimary))
        saveBtn.label = saveLabel
        saveBtn:SetAttribute("type", "macro")
        saveBtn:SetAttribute("macrotext", "/gmotd ")

        -- Update macrotext whenever the editbox text changes
        eb:SetScript("OnTextChanged", function(self, userInput)
            saveBtn:SetAttribute("macrotext", "/gmotd " .. self:GetText())
        end)

        -- Post-click: hide the editor after a short delay so the macro executes first
        saveBtn:HookScript("OnClick", function()
            local newText = eb:GetText()
            C_Timer.After(0.1, function()
                f:Hide()
                -- Immediately update the displayed MOTD from what was typed
                local infoParent = RF.mainFrame and RF.mainFrame.tabFrames and RF.mainFrame.tabFrames["Info"]
                if infoParent and infoParent.motdText then
                    infoParent.motdText:SetText(newText ~= "" and RF:ColorURLs(newText) or "(No MOTD set)")
                    infoParent._motdRaw = newText
                end
                RF:Print("MOTD updated.")
            end)
        end)

        eb:SetScript("OnEscapePressed", function() f:Hide() end)
        RF.motdEditorFrame = f
    end

    local motd = GetGuildRosterMOTD and GetGuildRosterMOTD() or ""
    RF.motdEditorFrame.editBox:SetText(motd)
    RF.motdEditorFrame:Show()
    RF.motdEditorFrame.editBox:SetFocus()
end

-------------------------------------------------------------------------------
-- News Feed Refresh
-------------------------------------------------------------------------------

local function ColorPlayer(name)
    if not name or name == "" then return "" end
    return "|cFF3B82F6" .. name .. "|r"
end

local function BuildNewsText(entry)
    local nt = entry.newsType or -1
    local dataID = entry.newsDataID or 0
    local who = entry.whoText or ""
    local what = entry.whatText or ""
    local coloredWho = ColorPlayer(who)

    if (nt == 0 or nt == 1) and dataID > 0 then
        local link = GetAchievementLink and GetAchievementLink(dataID)
        if link then
            if who ~= "" then return coloredWho .. " earned " .. link end
            return "Guild earned " .. link
        end
        if what ~= "" then
            local fallbackText = "|cFFFFFF00[" .. what .. "]|r"
            if who ~= "" then return coloredWho .. " earned " .. fallbackText end
            return fallbackText
        end
    end

    if (nt == 3 or nt == 4 or nt == 5 or nt == 8) and dataID > 0 then
        local link
        if C_Item and C_Item.GetItemInfo then
            _, link = C_Item.GetItemInfo(dataID)
        elseif GetItemInfo then
            _, link = GetItemInfo(dataID)
        end
        if not link and what ~= "" and what:find("|H") then
            link = what
        end
        if link then
            local verb
            if nt == 4 then verb = " crafted "
            elseif nt == 5 then verb = " purchased "
            elseif nt == 8 then verb = " obtained "
            else verb = " looted " end
            if who ~= "" then return coloredWho .. verb .. link end
            return link
        end
        if what ~= "" then
            local verb
            if nt == 4 then verb = " crafted "
            elseif nt == 5 then verb = " purchased "
            elseif nt == 8 then verb = " obtained "
            else verb = " looted " end
            if who ~= "" then return coloredWho .. verb .. what end
            return what
        end
    end

    if nt == 2 then
        local bossName = what
        if (not bossName or bossName == "") and EJ_GetEncounterInfo and dataID > 0 then
            bossName = EJ_GetEncounterInfo(dataID)
        end
        bossName = bossName or "Unknown"
        if who ~= "" then
            return coloredWho .. " defeated |cFFFF8000" .. bossName .. "|r"
        end
        return "|cFFFF8000" .. bossName .. "|r defeated"
    end

    if what ~= "" then
        return (who ~= "") and (coloredWho .. " - " .. what) or what
    end
    return "|cFF666670Guild news entry|r"
end

function RF:RefreshNewsFeed(parent)
    local nf = parent.newsFrame
    if not nf then return end
    nf:Clear()

    local liveEntries = {}
    local numNews = GetNumGuildNews and GetNumGuildNews() or 0

    for i = 1, math.min(numNews, 50) do
        local newsInfo
        if C_GuildInfo and C_GuildInfo.GetGuildNewsInfo then
            newsInfo = C_GuildInfo.GetGuildNewsInfo(i)
        end
        if newsInfo then
            tinsert(liveEntries, {
                newsType    = newsInfo.newsType,
                whoText     = newsInfo.whoText or "",
                whatText    = newsInfo.whatText or "",
                isSticky    = newsInfo.isSticky,
                newsDataID  = newsInfo.newsDataID or 0,
                data        = newsInfo.data,
                day         = newsInfo.day,
                month       = newsInfo.month,
                year        = newsInfo.year,
            })
        end
    end

    local guildDB = RF:GetGuildDB()
    local newsLog
    if #liveEntries > 0 and guildDB then
        guildDB.newsLog = liveEntries
        newsLog = liveEntries
    elseif guildDB and guildDB.newsLog then
        newsLog = guildDB.newsLog
    else
        newsLog = {}
    end

    local filter = parent.activeNewsFilter and parent.activeNewsFilter() or "All"
    local displayed = 0

    for _, entry in ipairs(newsLog) do
        local show = (filter == "All")
        if not show then
            local nt = entry.newsType
            if filter == "Achievements" and (nt == 0 or nt == 1) then show = true end
            if filter == "Loot" and (nt == 3 or nt == 4 or nt == 5 or nt == 8) then show = true end
            if filter == "Bosses" and nt == 2 then show = true end
        end

        if show then
            nf:AddMessage(BuildNewsText(entry))
            displayed = displayed + 1
        end
    end

    if displayed == 0 then
        nf:AddMessage("|cFF666670No guild news available.|r")
    end
end

-------------------------------------------------------------------------------
-- Guild Challenges Refresh
-------------------------------------------------------------------------------

function RF:RefreshGuildChallenges(parent)
    if not parent.challengeBars then return end

    if not EnsureChallengeAPI() then
        for _, typeIndex in ipairs(RF.GUILD_CHALLENGE_ORDER) do
            local bar = parent.challengeBars[typeIndex]
            if bar then
                bar.countText:SetText("—")
                bar.barFill:SetWidth(1)
            end
        end
        return
    end

    -- GetGuildChallengeInfo(i) takes a sequential index (1..N) and returns
    -- (challengeType, currentCount, maxCount).  Map by challengeType to bars.
    local numChallenges = GetNumGuildChallenges and GetNumGuildChallenges() or 4
    local hasData = false

    for i = 1, numChallenges do
        local challengeType, currentCount, maxCount = GetGuildChallengeInfo(i)
        if challengeType then
            local bar = parent.challengeBars[challengeType]
            if bar then
                currentCount = currentCount or 0
                maxCount = maxCount or 1

                if maxCount > 0 then hasData = true end

                bar.countText:SetText(currentCount .. "/" .. maxCount)

                local barWidth = bar.barFrame:GetWidth()
                if barWidth and barWidth > 0 and maxCount > 0 then
                    local fillWidth = math.max(1, (currentCount / maxCount) * barWidth)
                    bar.barFill:SetWidth(fillWidth)
                else
                    bar.barFill:SetWidth(1)
                end
            end
        end
    end

    -- If server hasn't responded yet, request and retry
    if not hasData then
        if RequestGuildChallengeInfo then
            RequestGuildChallengeInfo()
        end
        if not parent._challengeRetries then parent._challengeRetries = 0 end
        if parent._challengeRetries < 8 then
            parent._challengeRetries = parent._challengeRetries + 1
            C_Timer.After(2, function()
                RF:RefreshGuildChallenges(parent)
            end)
        end
    else
        parent._challengeRetries = 0
    end
end

-------------------------------------------------------------------------------
-- Calendar Events Refresh
-------------------------------------------------------------------------------
function RF:RefreshCalendarEvents(parent)
    local cf = parent.calendarFrame
    if not cf then return end
    cf:Clear()

    if not C_Calendar or not C_Calendar.OpenCalendar then
        cf:AddMessage("|cFF666670Calendar API unavailable.|r")
        return
    end

    C_Calendar.OpenCalendar()

    -- Store event references for click handling
    parent._calendarEvents = {}
    if not parent._calendarRetries then parent._calendarRetries = 0 end

    -- Wait for calendar data via event, with a short fallback timer
    if not parent._calendarListenerFrame then
        parent._calendarListenerFrame = CreateFrame("Frame")
    end
    local listenerFrame = parent._calendarListenerFrame
    listenerFrame:UnregisterAllEvents()
    listenerFrame:RegisterEvent("CALENDAR_UPDATE_EVENT_LIST")

    local loaded = false
    local function DoCalendarLoad()
        if loaded then return end
        loaded = true
        listenerFrame:UnregisterAllEvents()
        if not parent:IsShown() then return end
        cf:Clear()
        parent._calendarEvents = {}

        local today = C_DateAndTime and C_DateAndTime.GetCurrentCalendarTime()
        if not today then
            cf:AddMessage("|cFF666670Unable to read calendar date.|r")
            return
        end

        -- Ensure calendar view is on the current month
        C_Calendar.SetAbsMonth(today.month, today.year)

        local debugMode = RF.charDB and RF.charDB.debugMode
        local events = {}
        local rawEventCount = 0
        local seenTypes = {}

        for dayOffset = 0, 13 do
            local checkMonth = today.month
            local checkDay = today.monthDay + dayOffset
            local checkYear = today.year
            local monthOffset = 0

            -- Handle month rollover
            local daysInMonth = C_Calendar.GetMonthInfo(0)
            if daysInMonth and daysInMonth.numDays and checkDay > daysInMonth.numDays then
                checkDay = checkDay - daysInMonth.numDays
                monthOffset = 1
                checkMonth = checkMonth + 1
                if checkMonth > 12 then
                    checkMonth = 1
                    checkYear = checkYear + 1
                end

                -- Set calendar view to the rolled-over month so GetNumDayEvents works
                C_Calendar.SetAbsMonth(checkMonth, checkYear)
            end

            local numEvents = C_Calendar.GetNumDayEvents(monthOffset, checkDay)
            for i = 1, numEvents do
                local event = C_Calendar.GetDayEvent(monthOffset, checkDay, i)
                if event then
                    rawEventCount = rawEventCount + 1
                    local cType = event.calendarType or "UNKNOWN"
                    seenTypes[cType] = (seenTypes[cType] or 0) + 1

                    if cType == "GUILD" or cType == "GUILD_EVENT"
                        or cType == "PLAYER" or cType == "COMMUNITY_EVENT"
                        or cType == "GUILD_ANNOUNCEMENT" then
                        -- Skip multi-day events that aren't starting today
                        if event.sequenceType ~= "ONGOING" and event.sequenceType ~= "END" then
                            tinsert(events, {
                                title = event.title or "Event",
                                month = checkMonth,
                                day = checkDay,
                                year = checkYear,
                                hour = event.startTime and event.startTime.hour or 0,
                                minute = event.startTime and event.startTime.minute or 0,
                                endHour = event.endTime and event.endTime.hour,
                                endMinute = event.endTime and event.endTime.minute,
                                calendarType = cType,
                                invitedBy = event.invitedBy,
                                inviteStatus = event.inviteStatus,
                                difficultyName = event.difficultyName,
                                monthOffset = monthOffset,
                                dayIndex = i,
                                dayOffset = dayOffset,
                            })
                        end
                    end
                end
            end

            -- Reset calendar view back to current month after rollover scan
            if monthOffset == 1 then
                C_Calendar.SetAbsMonth(today.month, today.year)
            end
        end

        if debugMode then
            local typeList = {}
            for k, v in pairs(seenTypes) do tinsert(typeList, k .. "=" .. v) end
            RF:Debug("Calendar: " .. rawEventCount .. " raw events, types: " .. table.concat(typeList, ", "))
        end

        -- Fallback: if we found raw events but none matched known types, include them
        if #events == 0 and rawEventCount > 0 then
            for dayOffset = 0, 13 do
                local checkMonth = today.month
                local checkDay = today.monthDay + dayOffset
                local checkYear = today.year
                local monthOffset = 0
                local daysInMonth = C_Calendar.GetMonthInfo(0)
                if daysInMonth and daysInMonth.numDays and checkDay > daysInMonth.numDays then
                    checkDay = checkDay - daysInMonth.numDays
                    monthOffset = 1
                    checkMonth = checkMonth + 1
                    if checkMonth > 12 then checkMonth = 1; checkYear = checkYear + 1 end
                    C_Calendar.SetAbsMonth(checkMonth, checkYear)
                end
                local numEvents = C_Calendar.GetNumDayEvents(monthOffset, checkDay)
                for i = 1, numEvents do
                    local event = C_Calendar.GetDayEvent(monthOffset, checkDay, i)
                    if event and event.sequenceType ~= "ONGOING" and event.sequenceType ~= "END" then
                        tinsert(events, {
                            title = event.title or "Event",
                            month = checkMonth,
                            day = checkDay,
                            year = checkYear,
                            hour = event.startTime and event.startTime.hour or 0,
                            minute = event.startTime and event.startTime.minute or 0,
                            endHour = event.endTime and event.endTime.hour,
                            endMinute = event.endTime and event.endTime.minute,
                            calendarType = event.calendarType or "UNKNOWN",
                            invitedBy = event.invitedBy,
                            inviteStatus = event.inviteStatus,
                            difficultyName = event.difficultyName,
                            monthOffset = monthOffset,
                            dayIndex = i,
                            dayOffset = dayOffset,
                        })
                    end
                end
                if monthOffset == 1 then
                    C_Calendar.SetAbsMonth(today.month, today.year)
                end
            end
            if debugMode then
                RF:Debug("Calendar: fallback scan included " .. #events .. " events with unrecognized types")
            end
        end

        if #events == 0 then
            -- Calendar data may not be loaded yet; retry a few times
            if parent._calendarRetries < 3 then
                parent._calendarRetries = parent._calendarRetries + 1
                cf:AddMessage("|cFF666670Loading events...|r")
                C_Timer.After(1, function()
                    RF:RefreshCalendarEvents(parent)
                end)
            else
                cf:AddMessage("|cFF666670No upcoming events.|r")
            end
            return
        else
            parent._calendarRetries = 0
            for idx, ev in ipairs(events) do
                -- Store for click handling
                parent._calendarEvents[idx] = ev

                -- Date/time
                local timeStr = format("%02d:%02d", ev.hour, ev.minute)
                local dateStr
                if ev.dayOffset == 0 then
                    dateStr = "Today"
                elseif ev.dayOffset == 1 then
                    dateStr = "Tomorrow"
                else
                    dateStr = format("%d/%d", ev.month, ev.day)
                end

                -- End time
                local endStr = ""
                if ev.endHour and ev.endMinute then
                    endStr = format(" - %02d:%02d", ev.endHour, ev.endMinute)
                end

                -- Type tag
                local typeTag = ""
                if ev.calendarType == "GUILD" or ev.calendarType == "GUILD_EVENT" then
                    typeTag = "|cFF25CC66[Guild]|r "
                elseif ev.calendarType == "GUILD_ANNOUNCEMENT" then
                    typeTag = "|cFFFFCC00[Announcement]|r "
                elseif ev.calendarType == "PLAYER" then
                    typeTag = "|cFF9966FF[Player]|r "
                elseif ev.calendarType == "COMMUNITY_EVENT" then
                    typeTag = "|cFF33BBFF[Community]|r "
                else
                    typeTag = "|cFFAAAAAA[Event]|r "
                end

                -- Difficulty
                local diffStr = ""
                if ev.difficultyName and ev.difficultyName ~= "" then
                    diffStr = " |cFFAAAAAA(" .. ev.difficultyName .. ")|r"
                end

                -- Creator
                local byStr = ""
                if ev.invitedBy and ev.invitedBy ~= "" then
                    byStr = " |cFF666670by " .. ev.invitedBy .. "|r"
                end

                local line = format(
                    "|cFF888888%s %s%s|r  %s|cFF3B82F6|Hcalevent:%d|h%s|h|r%s%s",
                    dateStr, timeStr, endStr, typeTag, idx, ev.title, diffStr, byStr
                )
                cf:AddMessage(line)
            end
        end
    end

    listenerFrame:SetScript("OnEvent", function()
        DoCalendarLoad()
    end)
    -- Short fallback in case the event already fired before we registered
    C_Timer.After(0.5, DoCalendarLoad)
end

-------------------------------------------------------------------------------
-- Event Log Refresh
-------------------------------------------------------------------------------
function RF:RefreshEventLog(parent)
    local ef = parent.eventLogFrame
    if not ef then return end
    ef:Clear()

    local numEvents = GetNumGuildEvents and GetNumGuildEvents() or 0
    local entries = {}

    -- Collect live event log (most recent first)
    for i = numEvents, 1, -1 do
        local eventType, player1, player2, rankOrIndex, year, month, day, hour = GetGuildEventInfo(i)
        if eventType then
            tinsert(entries, {
                eventType = eventType,
                player1 = player1 or "",
                player2 = player2 or "",
                rankOrIndex = rankOrIndex or "",
                year = year or 0,
                month = month or 0,
                day = day or 0,
                hour = hour or 0,
            })
        end
    end

    -- Persist to guild DB
    local guildDB = RF:GetGuildDB()
    if #entries > 0 and guildDB then
        guildDB.eventLog = entries
    elseif guildDB and guildDB.eventLog and #guildDB.eventLog > 0 then
        entries = guildDB.eventLog
    end

    -- Display entries
    for _, entry in ipairs(entries) do
        local typeInfo = RF.GUILD_EVENT_TYPES[entry.eventType]
        local label = typeInfo and typeInfo.label or entry.eventType
        local color = typeInfo and typeInfo.color or { 0.5, 0.5, 0.5 }
        local colorHex = format("%02x%02x%02x", color[1] * 255, color[2] * 255, color[3] * 255)

        local timeText = RF:FormatTimeOffline(entry.year, entry.month, entry.day, entry.hour)
        local timeStr = "|cFF666670" .. timeText .. " ago|r"

        local text
        if entry.eventType == "invite" then
            text = format("%s |cFF%sinvited|r %s", ColorPlayer(entry.player1), colorHex, ColorPlayer(entry.player2))
        elseif entry.eventType == "join" then
            text = format("%s |cFF%s%s|r", ColorPlayer(entry.player1), colorHex, "joined")
        elseif entry.eventType == "promote" or entry.eventType == "demote" then
            local rankName = ""
            if type(entry.rankOrIndex) == "string" and entry.rankOrIndex ~= "" then
                rankName = entry.rankOrIndex
            elseif type(entry.rankOrIndex) == "number" and GuildControlGetRankName then
                rankName = GuildControlGetRankName(entry.rankOrIndex + 1) or ""
            end
            local verb = entry.eventType == "promote" and "promoted" or "demoted"
            text = format("%s |cFF%s%s|r %s", ColorPlayer(entry.player2), colorHex, verb, ColorPlayer(entry.player1))
            if rankName ~= "" then
                text = text .. " to " .. rankName
            end
        elseif entry.eventType == "remove" then
            text = format("%s |cFF%s%s|r %s", ColorPlayer(entry.player2), colorHex, "removed", ColorPlayer(entry.player1))
        elseif entry.eventType == "quit" then
            text = format("%s |cFF%s%s|r", ColorPlayer(entry.player1), colorHex, "left")
        else
            text = format("|cFF%s%s|r %s", colorHex, label, ColorPlayer(entry.player1))
        end

        ef:AddMessage(timeStr .. "  " .. text)
    end

    if #entries == 0 then
        ef:AddMessage("|cFF666670No event log entries.|r")
    end
end

-------------------------------------------------------------------------------
-- Guild Stats Refresh
-------------------------------------------------------------------------------
function RF:RefreshGuildStats(parent)
    local statsBg = parent.statsBg
    if not statsBg then return end

    for _, label in ipairs(parent.statsLabels) do
        label:Hide()
    end
    parent.statsLabels = {}

    local stats = RF:GetRosterStats()
    local yOffset = -8

    local function AddStatLine(text, color)
        local label = statsBg:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        label:SetFont(label:GetFont(), RF.FONT_SIZES.normal, "")
        label:SetPoint("TOPLEFT", 8, yOffset)
        label:SetPoint("RIGHT", -8, 0)
        label:SetJustifyH("LEFT")
        label:SetTextColor(unpack(color or RF.Theme.textSecondary))
        label:SetText(text)
        tinsert(parent.statsLabels, label)
        yOffset = yOffset - 18
        return label
    end

    AddStatLine("Total Members: " .. stats.total, RF.Theme.textPrimary)
    AddStatLine("Online: " .. stats.online, RF.Theme.online)
    AddStatLine("Offline: " .. stats.offline, RF.Theme.offline)
    AddStatLine(" ")

    AddStatLine("Class Distribution:", RF.Theme.textPrimary)

    local classList = {}
    for classFile, count in pairs(stats.classCounts) do
        tinsert(classList, { classFile = classFile, count = count })
    end
    table.sort(classList, function(a, b) return a.count > b.count end)

    for _, entry in ipairs(classList) do
        local cc = RF:GetClassColor(entry.classFile)
        local name = entry.classFile:sub(1,1) .. entry.classFile:sub(2):lower()
        local pct = stats.total > 0 and format(" (%.0f%%)", entry.count / stats.total * 100) or ""

        local barFrame = CreateFrame("Frame", nil, statsBg)
        barFrame:SetHeight(14)
        barFrame:SetPoint("TOPLEFT", 8, yOffset)
        barFrame:SetPoint("RIGHT", -8, 0)
        tinsert(parent.statsLabels, barFrame)

        local barBg = barFrame:CreateTexture(nil, "BACKGROUND")
        barBg:SetAllPoints()
        barBg:SetColorTexture(0.1, 0.1, 0.12, 1)

        local barFill = barFrame:CreateTexture(nil, "ARTWORK")
        barFill:SetPoint("TOPLEFT")
        barFill:SetPoint("BOTTOMLEFT")
        local maxWidth = 150
        local fillWidth = stats.total > 0 and math.max(1, (entry.count / stats.total) * maxWidth) or 1
        barFill:SetWidth(fillWidth)
        barFill:SetColorTexture(cc.r, cc.g, cc.b, 0.6)

        local barLabel = barFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        barLabel:SetFont(barLabel:GetFont(), RF.FONT_SIZES.small, "")
        barLabel:SetPoint("LEFT", 4, 0)
        barLabel:SetText(format("|cFF%02x%02x%02x%s|r %d%s", cc.r*255, cc.g*255, cc.b*255, name, entry.count, pct))

        yOffset = yOffset - 16
    end

    -- Size the scroll child to fit all content (scrollable within capped border)
    statsBg:SetHeight(math.abs(yOffset) + 4)

    if parent.statsScrollbar then
        C_Timer.After(0, parent.statsScrollbar.Update)
    end
end
