local ADDON_NAME, RPAL = ...

-- ============================================================
-- RPAL Calendar UI
-- A simple book-calendar: click a day, add an event, done.
-- ============================================================

local C={
    bg={0.022,0.016,0.034,0.985},page={0.065,0.043,0.060,0.98},
    panel={0.050,0.034,0.064,0.98},panel2={0.082,0.055,0.076,0.98},
    border={0.30,0.22,0.32,1},button={0.082,0.055,0.076,1},buttonHover={0.13,0.09,0.14,1},
    accent={0.25,0.16,0.08,1},accentHover={0.34,0.22,0.10,1},
    gold={0.93,0.72,0.27,1},pale={0.94,0.90,0.82,1},muted={0.62,0.59,0.63,1},
    green={0.42,0.90,0.61,1},red={0.94,0.38,0.40,1},
}
local FONT=STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"
local DISPLAY=FONT
local TEX="Interface\\AddOns\\ODYN\\Textures\\"
local CAL_ICON=RPAL.ICONS.list
local SPARK=TEX.."Talent\\sparkle.tga"
local TB=TEX.."TravelBookTheme\\"

local function CopyInto(dst,src)
    if not dst or not src then return end
    for i=1,4 do dst[i]=src[i] or dst[i] or 1 end
end

local function Mix(a,b,t,alpha)
    t=math.max(0,math.min(1,t or 0))
    return {
        (a[1] or 0)*(1-t)+(b[1] or 0)*t,
        (a[2] or 0)*(1-t)+(b[2] or 0)*t,
        (a[3] or 0)*(1-t)+(b[3] or 0)*t,
        alpha or ((a[4] or 1)*(1-t)+(b[4] or 1)*t)
    }
end

local function ApplyPalette()
    if not RPAL.GetCurrentThemePalette then return end
    local p=RPAL.GetCurrentThemePalette()
    if not p then return end
    CopyInto(C.bg,p.main);CopyInto(C.page,p.page);CopyInto(C.panel,p.panel);CopyInto(C.panel2,p.panel2)
    CopyInto(C.border,p.border);CopyInto(C.button,p.button);CopyInto(C.buttonHover,p.buttonHover)
    CopyInto(C.accent,p.accent);CopyInto(C.accentHover,p.accentHover)
    CopyInto(C.gold,p.gold);CopyInto(C.pale,p.pale);CopyInto(C.muted,p.muted)
end

local calendar
local calendarShade
local selectedYear
local selectedMonth
local selectedDay
local eventEditorShade
local calendarExchangeShade
local calendarExchangeQueue={}
local reminderToast
local reminderQueue={}
local calendarEditorSerial=0
local calendarSourceMode="all"
local communityDetail
local huginnBrowser

local function Scaled(n)
    return RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(n) or n
end

local function Text(parent,value,size,color,font)
    local f=parent:CreateFontString(nil,"OVERLAY")
    local fontPath=font or FONT
    local base=size or 12
    f:SetFont(fontPath,Scaled(base),"")
    if RPAL.RegisterScalableFontObject then RPAL.RegisterScalableFontObject(f,fontPath,base,"") end
    f:SetText(tostring(value or ""))
    color=color or C.pale
    f:SetTextColor(color[1],color[2],color[3],color[4] or 1)
    f:SetShadowColor(0,0,0,0.95);f:SetShadowOffset(1,-1)
    return f
end

local function Backdrop(frame,bg,border,edge)
    frame:SetBackdrop({
        bgFile="Interface\\Buttons\\WHITE8X8",
        edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",
        tile=false,tileSize=16,edgeSize=edge or 14,
        insets={left=3,right=3,top=3,bottom=3},
    })
    bg=bg or C.panel;border=border or C.gold
    frame:SetBackdropColor(bg[1],bg[2],bg[3],bg[4] or 1)
    frame:SetBackdropBorderColor(border[1],border[2],border[3],border[4] or 1)
end

local function Button(parent,label,w,h,accent)
    local b=CreateFrame("Button",nil,parent,"BackdropTemplate")
    b:SetSize(w or 90,h or 30)
    Backdrop(b,accent and C.accent or C.button,accent and C.gold or C.border,12)
    local paper=b:CreateTexture(nil,"BACKGROUND",nil,-1);paper:SetTexture(TB.."UI_TravelBook_Frame01a.tga");paper:SetPoint("TOPLEFT",2,-2);paper:SetPoint("BOTTOMRIGHT",-2,2);paper:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0.18);paper:SetAlpha(accent and 0.22 or 0.12);b.paper=paper
    b.label=Text(b,label,10.5,accent and C.pale or C.pale)
    b.label:SetPoint("CENTER")
    b:SetScript("OnEnter",function(self)
        local c=accent and C.accentHover or C.buttonHover
        self:SetBackdropColor(c[1],c[2],c[3],c[4] or 1)
        self:SetBackdropBorderColor(C.gold[1],C.gold[2],C.gold[3],0.95)
        if self.paper then self.paper:SetAlpha(0.28) end
    end)
    b:SetScript("OnLeave",function(self)
        Backdrop(self,accent and C.accent or C.button,accent and C.gold or C.border,12)
        if self.paper then self.paper:SetAlpha(accent and 0.22 or 0.12) end
    end)
    if RPAL.AttachButtonMotion then RPAL.AttachButtonMotion(b,0.018) end
    if RPAL.AttachFocusMark then RPAL.AttachFocusMark(b,C.gold) end
    return b
end

local function ClearChildren(frame)
    if not frame then return end
    local children={frame:GetChildren()}
    for _,child in ipairs(children) do child:Hide();child:SetParent(nil) end
    local regions={frame:GetRegions()}
    for _,region in ipairs(regions) do if region.Hide then region:Hide() end end
end

local function DaysInMonth(year,month)
    local nextYear,nextMonth=year,month+1
    if nextMonth>12 then nextMonth=1;nextYear=year+1 end
    local stamp=time({year=nextYear,month=nextMonth,day=1,hour=12,min=0,sec=0})-86400
    return tonumber(date("%d",stamp)) or 30
end

local function FirstWeekdayMonday(year,month)
    local wday=tonumber(date("%w",time({year=year,month=month,day=1,hour=12,min=0,sec=0}))) or 1
    return (wday+6)%7
end

local function SameDate(a,b)
    return a and b and a.year==b.year and a.month==b.month and a.day==b.day
end

local function DateLabel(year,month,day)
    return date("%A, %d %B %Y",time({year=year,month=month,day=day,hour=12,min=0,sec=0}))
end

local function TimeLabel(event)
    return string.format("%02d:%02d",tonumber(event.hour) or 0,tonumber(event.minute) or 0)
end

local function Raise(frame,level,strata)
    if not frame then return end
    frame:SetFrameStrata(strata or "FULLSCREEN_DIALOG")
    frame:SetFrameLevel(level or 9700)
    frame:SetToplevel(true)
end

local function CloseEventEditor()
    local shade=eventEditorShade
    eventEditorShade=nil
    if shade then
        shade:SetScript("OnUpdate",nil)
        shade:Hide()
    end
end

-- One parent, one strata, deterministic frame levels. Splitting the shade and
-- form into separate UIParent roots caused the form to flash behind its own
-- blocker when Calendar raised itself at the end of a click.
local function RaiseCalendarEventEditor(shade,panel)
    if not shade then return end
    Raise(shade,9800,"FULLSCREEN_DIALOG")
    if panel then
        if panel:GetParent()~=shade then panel:SetParent(shade) end
        panel:ClearAllPoints()
        panel:SetPoint("CENTER",shade,"CENTER",0,0)
        panel:SetFrameStrata("FULLSCREEN_DIALOG")
        panel:SetFrameLevel((shade:GetFrameLevel() or 9800)+20)
        panel:SetToplevel(true)
        if panel._rpalModalClose then
            panel._rpalModalClose:SetFrameLevel(panel:GetFrameLevel()+20)
        end
    end
    if shade.Raise then shade:Raise() end
    if panel and panel.Raise then panel:Raise() end
end

local function OpenEventEditor(year,month,day,event)
    ApplyPalette()
    CloseEventEditor()
    local modalTitle=event and "EDIT CALENDAR EVENT" or "NEW CALENDAR EVENT"
    local shade,f
    if RPAL.CreateModal then
        -- Calendar used to build a TOOLTIP-strata shade and a second
        -- TOOLTIP-strata panel by hand. On some clients the shade won that
        -- ordering race and swallowed every click while the form sat behind
        -- it. The shared modal owns one deterministic parent/child stack and
        -- is already registered with UISpecialFrames for ESC.
        shade,f=RPAL.CreateModal(modalTitle,560,470)
        RaiseCalendarEventEditor(shade,f)
    else
        calendarEditorSerial=calendarEditorSerial+1
        local frameName="RPAL_CalendarEventEditor_"..tostring(calendarEditorSerial)
        shade=CreateFrame("Frame",frameName,UIParent,"BackdropTemplate")
        shade:SetAllPoints(UIParent);shade:EnableMouse(true);Raise(shade,9800,"FULLSCREEN_DIALOG")
        shade:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8"});shade:SetBackdropColor(0,0,0,0.58)
        if RPAL.RegisterEscapeFrame then RPAL.RegisterEscapeFrame(shade)
        else table.insert(UISpecialFrames,frameName) end

        f=CreateFrame("Frame",nil,shade,"BackdropTemplate")
        f:SetSize(560,470);f:SetPoint("CENTER");f:SetFrameStrata("FULLSCREEN_DIALOG");f:SetFrameLevel((shade:GetFrameLevel() or 9800)+20);f:SetToplevel(true);f:EnableMouse(true);Backdrop(f,C.bg,C.gold,16)
        local paper=f:CreateTexture(nil,"BACKGROUND",nil,-2);paper:SetTexture(TB.."UI_TravelBook_Popup01a.tga");paper:SetPoint("TOPLEFT",4,-4);paper:SetPoint("BOTTOMRIGHT",-4,4);paper:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],1);paper:SetAlpha(0.16)
        local title=Text(f,modalTitle,20,C.gold,DISPLAY);title:SetPoint("TOPLEFT",22,-20)
    end
    eventEditorShade=shade
    RaiseCalendarEventEditor(shade,f)
    shade._rpalCalendarEventEditor=true
    shade._rpalEscapeFailsafe=true
    f._rpalCalendarEventPanel=true
    shade:HookScript("OnHide",function(self)
        self:SetScript("OnUpdate",nil)
        if f then f:Hide() end
        if eventEditorShade==self then eventEditorShade=nil end
    end)
    local dateLine=Text(f,DateLabel(year,month,day),10,C.muted);dateLine:SetPoint("TOPLEFT",23,-50)

    local close=f._rpalModalClose
    if close then close:SetScript("OnClick",CloseEventEditor)
    else
        close=Button(f,"×",34,28,false);close:SetPoint("TOPRIGHT",-14,-14);close.label:SetFont(DISPLAY,Scaled(18),"");close:SetScript("OnClick",CloseEventEditor)
    end
    shade._rpalCalendarEditorClose=close

    local l1=Text(f,"EVENT NAME",8,C.muted);l1:SetPoint("TOPLEFT",22,-88)
    local name=CreateFrame("EditBox",nil,f,"BackdropTemplate")
    name:SetPoint("TOPLEFT",22,-106);name:SetSize(516,38);Backdrop(name,C.panel2,C.border,12)
    name:SetFont(FONT,Scaled(12),"");name:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);name:SetTextInsets(10,10,0,0);name:SetAutoFocus(not event)
    name:SetText(event and event.title or "")
    name:SetScript("OnEscapePressed",CloseEventEditor)

    local l2=Text(f,"TIME",8,C.muted);l2:SetPoint("TOPLEFT",22,-164)
    local now=date("*t")
    local defaultHour=(year==now.year and month==now.month and day==now.day) and math.min(23,now.hour+1) or 20
    local hour=CreateFrame("EditBox",nil,f,"BackdropTemplate");hour:SetSize(54,36);hour:SetPoint("TOPLEFT",22,-183);Backdrop(hour,C.panel2,C.border,12)
    hour:SetFont(FONT,Scaled(12),"");hour:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);hour:SetJustifyH("CENTER");hour:SetAutoFocus(false);hour:SetMaxLetters(2);hour:SetNumeric(true);hour:SetText(string.format("%02d",event and (event.hour or 20) or defaultHour))
    hour:SetScript("OnEscapePressed",CloseEventEditor)
    local colon=Text(f,":",16,C.gold,DISPLAY);colon:SetPoint("LEFT",hour,"RIGHT",8,0)
    local minute=CreateFrame("EditBox",nil,f,"BackdropTemplate");minute:SetSize(54,36);minute:SetPoint("LEFT",colon,"RIGHT",8,0);Backdrop(minute,C.panel2,C.border,12)
    minute:SetFont(FONT,Scaled(12),"");minute:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);minute:SetJustifyH("CENTER");minute:SetAutoFocus(false);minute:SetMaxLetters(2);minute:SetNumeric(true);minute:SetText(string.format("%02d",event and (event.minute or 0) or 0))
    minute:SetScript("OnEscapePressed",CloseEventEditor)

    local reminder=Text(f,"ODYN reminds you on the day, then again during the final hour.",9,C.gold);reminder:SetPoint("LEFT",minute,"RIGHT",16,0)

    local l3=Text(f,"NOTE  (optional)",8,C.muted);l3:SetPoint("TOPLEFT",22,-245)
    local note=CreateFrame("EditBox",nil,f,"BackdropTemplate")
    note:SetPoint("TOPLEFT",22,-264);note:SetSize(516,112);Backdrop(note,C.panel2,C.border,12)
    note:SetFont(FONT,Scaled(11),"");note:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);note:SetTextInsets(10,10,10,10);note:SetMultiLine(true);note:SetAutoFocus(false);note:SetText(event and event.note or "")
    note:SetScript("OnEscapePressed",CloseEventEditor)
    shade._rpalCalendarEditorInputs={name,hour,minute,note}

    local save=Button(f,event and "Save Changes" or "Add to Calendar",128,34,true);save:SetPoint("BOTTOMRIGHT",-22,20)
    save:SetScript("OnClick",function()
        local h=math.max(0,math.min(23,tonumber(hour:GetText()) or 20))
        local min=math.max(0,math.min(59,tonumber(minute:GetText()) or 0))
        local ok,result
        if event then
            ok,result=RPAL.UpdateCalendarEvent(event.id,{title=name:GetText(),note=note:GetText(),year=year,month=month,day=day,hour=h,minute=min})
        else
            ok,result=RPAL.AddCalendarEvent(year,month,day,h,min,name:GetText(),note:GetText())
        end
        if not ok then
            print("|cffff7777ODYN Calendar:|r "..tostring(result or "Could not save event."))
            return
        end
        CloseEventEditor()
        RPAL.RefreshCalendar()
    end)
    if event then
        local share=Button(f,"Share Event",108,34,false);share:SetPoint("BOTTOMLEFT",22,20)
        share:SetScript("OnClick",function()if RPAL.OpenCalendarExchange then RPAL.OpenCalendarExchange(event) end end)
    end
    -- RPAL.CreateModal supplies the entrance transition. Keep the fallback
    -- path equally deterministic without replacing the shared animation.
    if not RPAL.CreateModal then
        shade:SetAlpha(0);local fadeAge=0
        shade:SetScript("OnUpdate",function(self,elapsed)fadeAge=fadeAge+(elapsed or 0);local k=math.min(1,fadeAge/0.18);self:SetAlpha(1-((1-k)*(1-k)));if k>=1 then self:SetScript("OnUpdate",nil) end end)
    end
    return shade,f
end
RPAL.OpenCalendarEventEditor=OpenEventEditor

-- ------------------------------------------------------------
-- Calendar Exchange UI
-- ------------------------------------------------------------
local activeCalendarExchangeOffer
local ShowNextCalendarExchangeOffer

local function ExchangeNotice(message,status)
    if RPAL.ShowSessionStatusToast then RPAL.ShowSessionStatusToast(message,status or "PAUSED")
    else print("|cffd9a644ODYN Exchange:|r "..tostring(message or "")) end
end

local function CloseCalendarExchange(handled)
    local shade=calendarExchangeShade
    local offer=activeCalendarExchangeOffer
    if offer and not handled then
        if offer.kind=="storyArc" and RPAL.DeclineStoryArcExchangeOffer then RPAL.DeclineStoryArcExchangeOffer(offer.sender,offer.id)
        elseif offer.kind=="dmCampaign" and RPAL.DeclineDMCampaignExchangeOffer then RPAL.DeclineDMCampaignExchangeOffer(offer.sender,offer.id)
        elseif RPAL.DeclineCalendarExchangeOffer then RPAL.DeclineCalendarExchangeOffer(offer.sender,offer.id) end
    end
    activeCalendarExchangeOffer=nil
    calendarExchangeShade=nil
    if shade then shade._rpalExchangeClosing=true;shade:Hide() end
    if ShowNextCalendarExchangeOffer and #calendarExchangeQueue>0 then
        if C_Timer and C_Timer.After then C_Timer.After(0,ShowNextCalendarExchangeOffer) else ShowNextCalendarExchangeOffer() end
    end
end

local function ExchangeModal(title,width,height)
    if calendarExchangeShade and calendarExchangeShade:IsShown() then
        if calendarExchangeShade.Raise then calendarExchangeShade:Raise() end
        return nil,nil
    end
    if calendarExchangeShade then calendarExchangeShade:Hide();calendarExchangeShade=nil end

    local shade,f
    if RPAL.CreateModal then
        shade,f=RPAL.CreateModal(title,width or 640,height or 500)
    else
        shade=CreateFrame("Frame",nil,UIParent,"BackdropTemplate")
        shade:SetAllPoints(UIParent);shade:EnableMouse(true);Raise(shade,15100,"FULLSCREEN_DIALOG")
        shade:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8"});shade:SetBackdropColor(0,0,0,0.72)
        f=CreateFrame("Frame",nil,shade,"BackdropTemplate");f:SetSize(width or 640,height or 500);f:SetPoint("CENTER");Raise(f,15120,"FULLSCREEN_DIALOG");Backdrop(f,C.bg,C.gold,18)
        local heading=Text(f,title,20,C.gold,DISPLAY);heading:SetPoint("TOPLEFT",22,-20)
        local close=Button(f,"×",34,28,false);close:SetPoint("TOPRIGHT",-14,-14);close.label:SetFont(DISPLAY,Scaled(18),"");f._rpalModalClose=close
    end
    calendarExchangeShade=shade
    shade._rpalExchangeInteractive=true
    if f.SetClampedToScreen then f:SetClampedToScreen(true) end
    if f.EnableMouse then f:EnableMouse(true) end
    if f._rpalModalClose then f._rpalModalClose:SetScript("OnClick",function()CloseCalendarExchange(false)end) end
    shade:HookScript("OnHide",function(self)
        if self._rpalExchangeClosing or calendarExchangeShade~=self then return end
        -- ESC and parent-driven closure must release the input shield too.
        calendarExchangeShade=nil
        local offer=activeCalendarExchangeOffer
        activeCalendarExchangeOffer=nil
        if offer then
            if offer.kind=="storyArc" and RPAL.DeclineStoryArcExchangeOffer then RPAL.DeclineStoryArcExchangeOffer(offer.sender,offer.id)
            elseif RPAL.DeclineCalendarExchangeOffer then RPAL.DeclineCalendarExchangeOffer(offer.sender,offer.id) end
        end
        if ShowNextCalendarExchangeOffer and #calendarExchangeQueue>0 then
            if C_Timer and C_Timer.After then C_Timer.After(0,ShowNextCalendarExchangeOffer) else ShowNextCalendarExchangeOffer() end
        end
    end)
    return shade,f
end
RPAL.CloseOnlineExchange=function()CloseCalendarExchange(false)end

local function CurrentTargetPlayerName()
    if UnitExists and not UnitExists("target") then return nil end
    if UnitIsPlayer and not UnitIsPlayer("target") then return nil end
    local name,realm
    if UnitFullName then name,realm=UnitFullName("target") end
    if (not name or name=="") and UnitName then name=UnitName("target") end
    if not name or name=="" then return nil end
    if realm and realm~="" then return tostring(name).."-"..tostring(realm) end
    return tostring(name)
end

local function EventPreview(parent,event,y)
    local panel=CreateFrame("Frame",nil,parent,"BackdropTemplate");panel:SetPoint("TOPLEFT",22,-y);panel:SetPoint("TOPRIGHT",-22,-y);panel:SetHeight(190);Backdrop(panel,C.panel2,C.border,14)
    local badge=Text(panel,"ONLY THIS CALENDAR EVENT",8,C.green);badge:SetPoint("TOPLEFT",14,-12)
    local title=Text(panel,event.title or "RP Event",16,C.pale,DISPLAY);title:SetPoint("TOPLEFT",14,-34);title:SetPoint("RIGHT",-14,0);title:SetWordWrap(false)
    local dateText=DateLabel(event.year,event.month,event.day).."  •  "..string.format("%02d:%02d",event.hour or 0,event.minute or 0)
    local dateLine=Text(panel,dateText,9.5,C.gold);dateLine:SetPoint("TOPLEFT",14,-62);dateLine:SetPoint("RIGHT",-14,0);dateLine:SetWordWrap(false)
    local noteLabel=Text(panel,"NOTE",7,C.muted);noteLabel:SetPoint("TOPLEFT",14,-89)
    local noteScroll=CreateFrame("ScrollFrame",nil,panel);noteScroll:SetPoint("TOPLEFT",12,-105);noteScroll:SetPoint("BOTTOMRIGHT",-12,10)
    local noteContent=CreateFrame("Frame",nil,noteScroll);noteContent:SetWidth(550);noteContent:SetHeight(math.max(60,math.ceil(#tostring(event.note or "")/72)*15));noteScroll:SetScrollChild(noteContent)
    local note=Text(noteContent,(event.note and event.note~="") and event.note or "No note.",9,C.muted);note:SetPoint("TOPLEFT",2,-2);note:SetWidth(538);note:SetWordWrap(true)
    return panel
end

function RPAL.OpenCalendarExchange(event)
    ApplyPalette()
    local copy,err
    if RPAL.MakeCalendarExchangeEvent then copy,err=RPAL.MakeCalendarExchangeEvent(event) end
    if not copy then ExchangeNotice(err or "That event cannot be shared.","OOC");return end
    local shade,f=ExchangeModal("SHARE CALENDAR EVENT",640,500);if not shade then return end
    local intro=Text(f,"Send an invitation to another online ODYN player. They see this exact preview and nothing is imported until they accept.",9,C.muted)
    intro:SetPoint("TOPLEFT",22,-54);intro:SetWidth(596);intro:SetWordWrap(true)
    EventPreview(f,copy,88)
    local targetLabel=Text(f,"PLAYER NAME",8,C.muted);targetLabel:SetPoint("TOPLEFT",22,-297)
    local target=CreateFrame("EditBox",nil,f,"BackdropTemplate");target:SetPoint("TOPLEFT",22,-315);target:SetSize(390,36);Backdrop(target,C.panel2,C.border,12)
    target:SetFont(FONT,Scaled(11),"");target:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);target:SetTextInsets(9,9,0,0);target:SetAutoFocus(false);target:SetMaxLetters(96);target:SetText(CurrentTargetPlayerName() or "")
    local safety=Text(f,"Calendar-only schema  •  no achievements, Renown, cosmetics or character data",8,C.green);safety:SetPoint("TOPLEFT",22,-365);safety:SetWidth(590)
    local status=Text(f,"",8,C.red);status:SetPoint("TOPLEFT",22,-389);status:SetWidth(590)
    local function SendTo(value)
        local ok,result=RPAL.SendCalendarEventExchange and RPAL.SendCalendarEventExchange(event,value)
        if not ok then status:SetText(tostring(result or "Could not send the invitation."));return end
        CloseCalendarExchange(true);ExchangeNotice("Calendar invitation sent to "..tostring(value)..". Waiting for their approval.","PAUSED")
    end
    local send=Button(f,"Send Invitation",126,34,true);send:SetPoint("BOTTOMRIGHT",-22,20);send:SetScript("OnClick",function()SendTo(target:GetText())end)
    local targetName=CurrentTargetPlayerName()
    local sendTarget=Button(f,"Use Target",98,34,false);sendTarget:SetPoint("RIGHT",send,"LEFT",-8,0);sendTarget:SetScript("OnClick",function()
        local value=CurrentTargetPlayerName();if not value then status:SetText("Target another player first.");return end
        target:SetText(value);SendTo(value)
    end)
    target:SetScript("OnEnterPressed",function(self)SendTo(self:GetText())end)
    shade._rpalExchangeDirection="outgoing";shade._rpalExchangeEvent=copy;shade._rpalExchangeTarget=target;shade._rpalTargetButton=sendTarget
    return shade,f
end

local function StoryArcPreview(parent,preview,y)
    local panel=CreateFrame("Frame",nil,parent,"BackdropTemplate");panel:SetPoint("TOPLEFT",22,-y);panel:SetPoint("TOPRIGHT",-22,-y);panel:SetHeight(270);Backdrop(panel,C.panel2,C.border,14)
    local badge=Text(panel,"ONLY THIS STORY ARC FILE",8,C.green);badge:SetPoint("TOPLEFT",14,-12)
    local title=Text(panel,preview.title or "Story Arc",16,C.pale,DISPLAY);title:SetPoint("TOPLEFT",14,-34);title:SetPoint("RIGHT",-14,0);title:SetWordWrap(false)
    local metaText=string.format("%d events  •  %d connections  •  %d Bonds",tonumber(preview.eventCount) or #(preview.events or {}),tonumber(preview.linkCount) or 0,tonumber(preview.bondCount) or #(preview.bondNames or {}))
    if preview.musicName and preview.musicName~="" then metaText=metaText.."  •  "..preview.musicName end
    local meta=Text(panel,metaText,8.5,C.gold);meta:SetPoint("TOPLEFT",14,-61);meta:SetPoint("RIGHT",-14,0);meta:SetWordWrap(false)
    local scroll=CreateFrame("ScrollFrame",nil,panel,"UIPanelScrollFrameTemplate");scroll:SetPoint("TOPLEFT",12,-88);scroll:SetPoint("BOTTOMRIGHT",-30,10)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    local content=CreateFrame("Frame",nil,scroll);content:SetWidth(580);scroll:SetScrollChild(content)
    local cursor=2
    local noteLabel=Text(content,"FILE NOTE",7,C.muted);noteLabel:SetPoint("TOPLEFT",2,-cursor);cursor=cursor+17
    local noteValue=tostring(preview.note or "");if noteValue=="" then noteValue="No file note." end
    local noteHeight=math.max(18,math.ceil(#noteValue/84)*14)
    local note=Text(content,noteValue,8.5,C.pale);note:SetPoint("TOPLEFT",2,-cursor);note:SetWidth(558);note:SetWordWrap(true);cursor=cursor+noteHeight+13
    local eventsLabel=Text(content,"EVENTS",7,C.muted);eventsLabel:SetPoint("TOPLEFT",2,-cursor);cursor=cursor+18
    for index,event in ipairs(preview.events or {}) do
        local eventTitle=Text(content,tostring(index)..".  "..tostring(event.title or "Event"),9,C.pale,DISPLAY);eventTitle:SetPoint("TOPLEFT",2,-cursor);eventTitle:SetWidth(370);eventTitle:SetWordWrap(false)
        local place=(event.location and event.location~="") and event.location or "No location"
        local location=Text(content,place,8,C.gold);location:SetPoint("TOPRIGHT",-18,-cursor);location:SetWidth(175);location:SetJustifyH("RIGHT");location:SetWordWrap(false)
        cursor=cursor+20
        local eventNote=tostring(event.note or "")
        if eventNote~="" then
            local eventNoteHeight=math.max(13,math.ceil(#eventNote/88)*12)
            local eventNoteText=Text(content,eventNote,7.8,C.muted);eventNoteText:SetPoint("TOPLEFT",20,-cursor);eventNoteText:SetWidth(535);eventNoteText:SetWordWrap(true)
            cursor=cursor+eventNoteHeight+7
        else cursor=cursor+7 end
    end
    if #(preview.bondNames or {})>0 then
        cursor=cursor+3
        local bonds=Text(content,"BONDS  •  "..table.concat(preview.bondNames,", "),8,C.muted);bonds:SetPoint("TOPLEFT",2,-cursor);bonds:SetWidth(558);bonds:SetWordWrap(true)
        cursor=cursor+math.max(18,math.ceil(#table.concat(preview.bondNames,", ")/82)*13)
    end
    content:SetHeight(math.max(160,cursor+12))
    return panel
end


local function DMCampaignPreview(parent,preview,y)
    local panel=CreateFrame("Frame",nil,parent,"BackdropTemplate");panel:SetPoint("TOPLEFT",22,-y);panel:SetPoint("TOPRIGHT",-22,-y);panel:SetHeight(190);Backdrop(panel,C.panel2,C.border,14)
    local badge=Text(panel,"DM CAMPAIGN PREPARATION FILE",8,C.green);badge:SetPoint("TOPLEFT",14,-12)
    local title=Text(panel,preview.name or "DM Campaign",16,C.pale,DISPLAY);title:SetPoint("TOPLEFT",14,-35);title:SetPoint("RIGHT",-14,0);title:SetWordWrap(false)
    local meta=Text(panel,string.format("%d NPCs  •  %d folders  •  %d encounter templates  •  %d custom statuses",tonumber(preview.npcs) or 0,tonumber(preview.folders) or 0,tonumber(preview.templates) or 0,tonumber(preview.statuses) or 0),8.5,C.gold)
    meta:SetPoint("TOPLEFT",14,-64);meta:SetPoint("RIGHT",-14,0);meta:SetWordWrap(false)
    local line1=Text(panel,"Includes campaign notes, folder structure, NPC files, encounter templates and custom status definitions.",8.5,C.pale);line1:SetPoint("TOPLEFT",14,-98);line1:SetWidth(600);line1:SetWordWrap(true)
    local line2=Text(panel,"Live/paused sessions, player identities, health state and historical session logs are not transferred.",8.2,C.muted);line2:SetPoint("TOPLEFT",14,-132);line2:SetWidth(600);line2:SetWordWrap(true)
    return panel
end

function RPAL.OpenDMCampaignOnlineExchange(campaignID)
    ApplyPalette()
    local dm=RPAL.DMKit
    if not dm or not dm.ExportCampaign or not dm.GetCampaignExchangePreview then ExchangeNotice("DM Campaign Exchange is not available.","OOC");return end
    local payload,err=dm.ExportCampaign(campaignID)
    local preview,previewError=payload and dm.GetCampaignExchangePreview(payload) or nil
    if not preview then ExchangeNotice(previewError or err or "That DM Campaign cannot be shared.","OOC");return end
    local shade,f=ExchangeModal("SHARE DM CAMPAIGN",700,545);if not shade then return end
    local intro=Text(f,"Send this campaign preparation file to another online ODYN player. They receive a preview and must approve the import.",9,C.muted)
    intro:SetPoint("TOPLEFT",22,-54);intro:SetWidth(656);intro:SetWordWrap(true)
    DMCampaignPreview(f,preview,88)
    local targetLabel=Text(f,"PLAYER NAME",8,C.muted);targetLabel:SetPoint("TOPLEFT",22,-292)
    local target=CreateFrame("EditBox",nil,f,"BackdropTemplate");target:SetPoint("TOPLEFT",22,-310);target:SetSize(430,36);Backdrop(target,C.panel2,C.border,12)
    target:SetFont(FONT,Scaled(11),"");target:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);target:SetTextInsets(9,9,0,0);target:SetAutoFocus(false);target:SetMaxLetters(96);target:SetText(CurrentTargetPlayerName() or "")
    local safety=Text(f,"DM Campaign-only schema  •  no character progression, cosmetics or live participant data",8,C.green);safety:SetPoint("TOPLEFT",22,-359);safety:SetWidth(650)
    local status=Text(f,"",8,C.red);status:SetPoint("TOPLEFT",22,-384);status:SetWidth(650)
    local function SendTo(value)
        local ok,result=RPAL.SendDMCampaignExchange and RPAL.SendDMCampaignExchange(campaignID,value)
        if not ok then status:SetText(tostring(result or "Could not send the DM Campaign invitation."));return end
        CloseCalendarExchange(true);ExchangeNotice("DM Campaign invitation sent to "..tostring(value)..". Waiting for their approval.","PAUSED")
    end
    local send=Button(f,"Send Invitation",126,34,true);send:SetPoint("BOTTOMRIGHT",-22,20);send:SetScript("OnClick",function()SendTo(target:GetText())end)
    local sendTarget=Button(f,"Use Target",98,34,false);sendTarget:SetPoint("RIGHT",send,"LEFT",-8,0);sendTarget:SetScript("OnClick",function()
        local value=CurrentTargetPlayerName();if not value then status:SetText("Target another player first.");return end
        target:SetText(value);SendTo(value)
    end)
    target:SetScript("OnEnterPressed",function(self)SendTo(self:GetText())end)
    shade._rpalExchangeDirection="outgoing-dm";shade._rpalExchangeDM=preview;shade._rpalExchangeTarget=target;shade._rpalTargetButton=sendTarget
    return shade,f
end

function RPAL.OpenStoryArcOnlineExchange(arcOrID)
    ApplyPalette()
    local payload,err
    if RPAL.ExportStoryArc then payload,err=RPAL.ExportStoryArc(arcOrID) end
    local preview,previewError
    if payload and RPAL.GetStoryArcExchangePreview then preview,previewError=RPAL.GetStoryArcExchangePreview(payload) end
    if not preview then ExchangeNotice(previewError or err or "That Story Arc cannot be shared.","OOC");return end
    local shade,f=ExchangeModal("SHARE STORY ARC",680,570);if not shade then return end
    local intro=Text(f,"Send this Story Arc file to another online ODYN player. They can inspect its file note, events, locations and Bonds before accepting.",9,C.muted)
    intro:SetPoint("TOPLEFT",22,-54);intro:SetWidth(636);intro:SetWordWrap(true)
    StoryArcPreview(f,preview,88)
    local targetLabel=Text(f,"PLAYER NAME",8,C.muted);targetLabel:SetPoint("TOPLEFT",22,-371)
    local target=CreateFrame("EditBox",nil,f,"BackdropTemplate");target:SetPoint("TOPLEFT",22,-389);target:SetSize(420,36);Backdrop(target,C.panel2,C.border,12)
    target:SetFont(FONT,Scaled(11),"");target:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);target:SetTextInsets(9,9,0,0);target:SetAutoFocus(false);target:SetMaxLetters(96);target:SetText(CurrentTargetPlayerName() or "")
    local safety=Text(f,"Story Arc-only schema  •  no achievements, Renown, cosmetics, Sessions or character data",8,C.green);safety:SetPoint("TOPLEFT",22,-438);safety:SetWidth(630)
    local status=Text(f,"",8,C.red);status:SetPoint("TOPLEFT",22,-462);status:SetWidth(630)
    local function SendTo(value)
        local ok,result=RPAL.SendStoryArcExchange and RPAL.SendStoryArcExchange(arcOrID,value)
        if not ok then status:SetText(tostring(result or "Could not send the Story Arc invitation."));return end
        CloseCalendarExchange(true);ExchangeNotice("Story Arc invitation sent to "..tostring(value)..". Waiting for their approval.","PAUSED")
    end
    local send=Button(f,"Send Invitation",126,34,true);send:SetPoint("BOTTOMRIGHT",-22,20);send:SetScript("OnClick",function()SendTo(target:GetText())end)
    local sendTarget=Button(f,"Use Target",98,34,false);sendTarget:SetPoint("RIGHT",send,"LEFT",-8,0);sendTarget:SetScript("OnClick",function()
        local value=CurrentTargetPlayerName();if not value then status:SetText("Target another player first.");return end
        target:SetText(value);SendTo(value)
    end)
    target:SetScript("OnEnterPressed",function(self)SendTo(self:GetText())end)
    shade._rpalExchangeDirection="outgoing-story";shade._rpalExchangeStory=preview;shade._rpalExchangeTarget=target;shade._rpalTargetButton=sendTarget
    return shade,f
end

local function OpenIncomingStoryArcExchange(offer)
    ApplyPalette();activeCalendarExchangeOffer=offer
    local shade,f=ExchangeModal("STORY ARC INVITATION",680,590);if not shade then return false end
    activeCalendarExchangeOffer=offer
    local from=Text(f,"FROM  "..tostring(offer.sender or "Unknown Player"),9,C.gold);from:SetPoint("TOPLEFT",22,-55)
    local intro=Text(f,"Review the complete summary below. Accept creates one new Story Arc file; Decline changes nothing.",9,C.muted);intro:SetPoint("TOPLEFT",22,-76);intro:SetWidth(636);intro:SetWordWrap(true)
    StoryArcPreview(f,offer.preview,108)
    local safety=Text(f,"WILL IMPORT: this Story Arc, its events, connections, locations and referenced Bond names",8,C.green);safety:SetPoint("TOPLEFT",22,-390);safety:SetWidth(630)
    local blocked=Text(f,"WILL NOT IMPORT: achievements, Renown, Talents, cosmetics, Sessions or profile data",8,C.muted);blocked:SetPoint("TOPLEFT",22,-414);blocked:SetWidth(630)
    local status=Text(f,"",8,C.red);status:SetPoint("TOPLEFT",22,-440);status:SetWidth(630)
    local decline=Button(f,"Decline",96,34,false);decline:SetPoint("BOTTOMLEFT",22,20);decline:SetScript("OnClick",function()
        RPAL.DeclineStoryArcExchangeOffer(offer.sender,offer.id);CloseCalendarExchange(true);ExchangeNotice("Story Arc invitation declined.","OOC")
    end)
    local accept=Button(f,"Accept & Add",118,34,true);accept:SetPoint("BOTTOMRIGHT",-22,20);accept:SetScript("OnClick",function()
        local ok,result=RPAL.AcceptStoryArcExchangeOffer(offer.sender,offer.id)
        if not ok then status:SetText(tostring(result or "The Story Arc could not be added."));return end
        CloseCalendarExchange(true);ExchangeNotice("Story Arc added: "..tostring(offer.preview.title),"IC")
        if RPAL.RefreshChronicle then RPAL.RefreshChronicle() end
    end)
    shade._rpalExchangeDirection="incoming-story";shade._rpalExchangeOffer=offer;shade._rpalAcceptButton=accept;shade._rpalDeclineButton=decline
    return true
end


local function OpenIncomingDMCampaignExchange(offer)
    ApplyPalette();activeCalendarExchangeOffer=offer
    local shade,f=ExchangeModal("DM CAMPAIGN INVITATION",700,550);if not shade then return false end
    activeCalendarExchangeOffer=offer
    local from=Text(f,"FROM  "..tostring(offer.sender or "Unknown Player"),9,C.gold);from:SetPoint("TOPLEFT",22,-55)
    local intro=Text(f,"Review the campaign summary below. Accept creates a separate new campaign; it never overwrites an existing campaign.",9,C.muted);intro:SetPoint("TOPLEFT",22,-76);intro:SetWidth(656);intro:SetWordWrap(true)
    DMCampaignPreview(f,offer.preview,108)
    local safety=Text(f,"WILL IMPORT: campaign notes, folders, NPCs, encounter templates and custom statuses",8,C.green);safety:SetPoint("TOPLEFT",22,-312);safety:SetWidth(650)
    local blocked=Text(f,"WILL NOT IMPORT: paused/live state, players, session history, achievements, Renown or cosmetics",8,C.muted);blocked:SetPoint("TOPLEFT",22,-336);blocked:SetWidth(650)
    local status=Text(f,"",8,C.red);status:SetPoint("TOPLEFT",22,-364);status:SetWidth(650)
    local decline=Button(f,"Decline",96,34,false);decline:SetPoint("BOTTOMLEFT",22,20);decline:SetScript("OnClick",function()
        RPAL.DeclineDMCampaignExchangeOffer(offer.sender,offer.id);CloseCalendarExchange(true);ExchangeNotice("DM Campaign invitation declined.","OOC")
    end)
    local accept=Button(f,"Accept & Add",118,34,true);accept:SetPoint("BOTTOMRIGHT",-22,20);accept:SetScript("OnClick",function()
        local ok,result=RPAL.AcceptDMCampaignExchangeOffer(offer.sender,offer.id)
        if not ok then status:SetText(tostring(result or "The DM Campaign could not be added."));return end
        CloseCalendarExchange(true);ExchangeNotice("DM Campaign added: "..tostring(result and result.name or offer.preview.name),"IC")
        if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    end)
    shade._rpalExchangeDirection="incoming-dm";shade._rpalExchangeOffer=offer;shade._rpalAcceptButton=accept;shade._rpalDeclineButton=decline
    return true
end

ShowNextCalendarExchangeOffer=function()
    if calendarExchangeShade and calendarExchangeShade:IsShown() then return end
    local offer=table.remove(calendarExchangeQueue,1);if not offer then return end
    if offer.kind=="storyArc" then
        if not OpenIncomingStoryArcExchange(offer) then table.insert(calendarExchangeQueue,1,offer) end
        return
    elseif offer.kind=="dmCampaign" then
        if not OpenIncomingDMCampaignExchange(offer) then table.insert(calendarExchangeQueue,1,offer) end
        return
    end
    ApplyPalette();activeCalendarExchangeOffer=offer
    local shade,f=ExchangeModal("CALENDAR INVITATION",640,520);if not shade then table.insert(calendarExchangeQueue,1,offer);return end
    activeCalendarExchangeOffer=offer
    local from=Text(f,"FROM  "..tostring(offer.sender or "Unknown Player"),9,C.gold);from:SetPoint("TOPLEFT",22,-55)
    local intro=Text(f,"Review every field below. Accept imports only this Calendar event; Decline changes nothing.",9,C.muted);intro:SetPoint("TOPLEFT",22,-76);intro:SetWidth(596);intro:SetWordWrap(true)
    EventPreview(f,offer.event,108)
    local safety=Text(f,"WILL IMPORT: title, date, time and note only",8,C.green);safety:SetPoint("TOPLEFT",22,-314)
    local blocked=Text(f,"WILL NOT IMPORT: achievements, Renown, Talents, cosmetics, Sessions or profile data",8,C.muted);blocked:SetPoint("TOPLEFT",22,-337);blocked:SetWidth(590)
    local status=Text(f,"",8,C.red);status:SetPoint("TOPLEFT",22,-365);status:SetWidth(590)
    local decline=Button(f,"Decline",96,34,false);decline:SetPoint("BOTTOMLEFT",22,20);decline:SetScript("OnClick",function()
        RPAL.DeclineCalendarExchangeOffer(offer.sender,offer.id);CloseCalendarExchange(true);ExchangeNotice("Calendar invitation declined.","OOC")
    end)
    local accept=Button(f,"Accept & Add",118,34,true);accept:SetPoint("BOTTOMRIGHT",-22,20);accept:SetScript("OnClick",function()
        local ok,result=RPAL.AcceptCalendarExchangeOffer(offer.sender,offer.id)
        if not ok then status:SetText(tostring(result or "The event could not be added."));return end
        CloseCalendarExchange(true);ExchangeNotice("Calendar event added: "..tostring(offer.event.title),"IC")
        if RPAL.RefreshCalendar then RPAL.RefreshCalendar() end
    end)
    shade._rpalExchangeDirection="incoming";shade._rpalExchangeOffer=offer;shade._rpalAcceptButton=accept;shade._rpalDeclineButton=decline
end

function RPAL.OnCalendarExchangeOffer(sender,event,transferID)
    if #calendarExchangeQueue>=5 then
        if RPAL.DeclineCalendarExchangeOffer then RPAL.DeclineCalendarExchangeOffer(sender,transferID) end
        return
    end
    calendarExchangeQueue[#calendarExchangeQueue+1]={kind="calendar",sender=sender,event=event,id=transferID}
    ShowNextCalendarExchangeOffer()
end

function RPAL.OnCalendarExchangeResult(kind,sender,event,reason)
    if kind=="A" then ExchangeNotice(tostring(sender).." accepted “"..tostring(event and event.title or "Calendar event").."”.","IC")
    elseif kind=="X" then ExchangeNotice(tostring(sender).." declined the Calendar invitation.","OOC")
    else ExchangeNotice("Calendar Exchange failed: "..tostring(reason or "invalid invitation"),"OOC") end
end

function RPAL.OnStoryArcExchangeOffer(sender,preview,payload,transferID)
    if #calendarExchangeQueue>=5 then
        if RPAL.DeclineStoryArcExchangeOffer then RPAL.DeclineStoryArcExchangeOffer(sender,transferID) end
        return
    end
    calendarExchangeQueue[#calendarExchangeQueue+1]={kind="storyArc",sender=sender,preview=preview,payload=payload,id=transferID}
    ShowNextCalendarExchangeOffer()
end

function RPAL.OnStoryArcExchangeResult(kind,sender,preview,reason)
    if kind=="A" then ExchangeNotice(tostring(sender).." accepted “"..tostring(preview and preview.title or "Story Arc").."”.","IC")
    elseif kind=="X" then ExchangeNotice(tostring(sender).." declined the Story Arc invitation.","OOC")
    else ExchangeNotice("Story Arc Exchange failed: "..tostring(reason or "invalid invitation"),"OOC") end
end


function RPAL.OnDMCampaignExchangeOffer(sender,preview,payload,transferID)
    if #calendarExchangeQueue>=5 then
        if RPAL.DeclineDMCampaignExchangeOffer then RPAL.DeclineDMCampaignExchangeOffer(sender,transferID) end
        return
    end
    calendarExchangeQueue[#calendarExchangeQueue+1]={kind="dmCampaign",sender=sender,preview=preview,payload=payload,id=transferID}
    ShowNextCalendarExchangeOffer()
end

function RPAL.OnDMCampaignExchangeResult(kind,sender,preview,reason)
    if kind=="A" then ExchangeNotice(tostring(sender).." accepted “"..tostring(preview and preview.name or "DM Campaign").."”.","IC")
    elseif kind=="X" then ExchangeNotice(tostring(sender).." declined the DM Campaign invitation.","OOC")
    else ExchangeNotice("DM Campaign Exchange failed: "..tostring(reason or "invalid invitation"),"OOC") end
end

function RPAL.OpenExchangeHub()
    ApplyPalette()
    local shade,f=ExchangeModal("ONLINE EXCHANGE",700,560);if not shade then return end
    shade._rpalExchangeChoiceButtons={}
    local intro=Text(f,"Choose exactly one item to send. Every recipient gets a read-only preview and must approve it before ODYN adds anything.",9,C.muted)
    intro:SetPoint("TOPLEFT",22,-54);intro:SetWidth(636);intro:SetWordWrap(true)
    local safety=Text(f,"SUPPORTED: Calendar events + Story Arcs + DM Campaigns  •  character/progression data is never transferable",8,C.green);safety:SetPoint("TOPLEFT",22,-86)
    local scroll=CreateFrame("ScrollFrame",nil,f,"UIPanelScrollFrameTemplate");scroll:SetPoint("TOPLEFT",22,-116);scroll:SetPoint("BOTTOMRIGHT",-38,22)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    local events=RPAL.GetCalendarEvents and RPAL.GetCalendarEvents() or {}
    local arcs=RPAL.GetStoryArcs and RPAL.GetStoryArcs() or {}
    local campaigns=RPAL.DMKit and RPAL.DMKit.GetCampaigns and RPAL.DMKit.GetCampaigns() or {}
    local content=CreateFrame("Frame",nil,scroll);content:SetWidth(630);scroll:SetScrollChild(content)
    local cursor=0
    local calendarLabel=Text(content,"CALENDAR EVENTS",8,C.gold);calendarLabel:SetPoint("TOPLEFT",4,-cursor);cursor=cursor+23
    if #events==0 then local empty=Text(content,"No Calendar events available.",9,C.muted);empty:SetPoint("TOPLEFT",10,-cursor);cursor=cursor+30 end
    for index,event in ipairs(events) do
        local currentEvent=event
        local row=CreateFrame("Frame",nil,content,"BackdropTemplate");row:SetSize(620,54);row:SetPoint("TOPLEFT",0,-cursor);Backdrop(row,C.panel2,C.border,12)
        local title=Text(row,event.title or "RP Event",11,C.pale,DISPLAY);title:SetPoint("TOPLEFT",12,-8);title:SetWidth(440);title:SetWordWrap(false)
        local meta=Text(row,DateLabel(event.year,event.month,event.day).."  •  "..TimeLabel(event),8,C.gold);meta:SetPoint("TOPLEFT",12,-30);meta:SetWidth(440);meta:SetWordWrap(false)
        local share=Button(row,"Share",82,28,true);share:SetPoint("RIGHT",-10,0);share:SetScript("OnClick",function()
            CloseCalendarExchange(true);RPAL.OpenCalendarExchange(currentEvent)
        end)
        shade._rpalExchangeChoiceButtons[#shade._rpalExchangeChoiceButtons+1]=share
        cursor=cursor+62
    end
    cursor=cursor+8
    local arcLabel=Text(content,"STORY ARCS",8,C.gold);arcLabel:SetPoint("TOPLEFT",4,-cursor);cursor=cursor+23
    if #arcs==0 then local empty=Text(content,"No Story Arc files available.",9,C.muted);empty:SetPoint("TOPLEFT",10,-cursor);cursor=cursor+30 end
    for _,arc in ipairs(arcs) do
        local currentArc=arc;local stats=RPAL.GetStoryArcStats and RPAL.GetStoryArcStats(arc) or {steps=#(arc.stepOrder or {}),links=#(arc.links or {})}
        local row=CreateFrame("Frame",nil,content,"BackdropTemplate");row:SetSize(620,54);row:SetPoint("TOPLEFT",0,-cursor);Backdrop(row,C.panel2,C.border,12)
        local title=Text(row,arc.title or "Story Arc",11,C.pale,DISPLAY);title:SetPoint("TOPLEFT",12,-8);title:SetWidth(440);title:SetWordWrap(false)
        local meta=Text(row,string.format("%d events  •  %d connections",tonumber(stats.steps) or 0,tonumber(stats.links) or 0),8,C.gold);meta:SetPoint("TOPLEFT",12,-30);meta:SetWidth(440);meta:SetWordWrap(false)
        local share=Button(row,"Share",82,28,true);share:SetPoint("RIGHT",-10,0);share:SetScript("OnClick",function()
            CloseCalendarExchange(true);RPAL.OpenStoryArcOnlineExchange(currentArc)
        end)
        shade._rpalExchangeChoiceButtons[#shade._rpalExchangeChoiceButtons+1]=share
        cursor=cursor+62
    end
    cursor=cursor+8
    local dmLabel=Text(content,"DM CAMPAIGNS",8,C.gold);dmLabel:SetPoint("TOPLEFT",4,-cursor);cursor=cursor+23
    if #campaigns==0 then local empty=Text(content,"No DM Campaign files available.",9,C.muted);empty:SetPoint("TOPLEFT",10,-cursor);cursor=cursor+30 end
    for _,campaign in ipairs(campaigns) do
        local currentCampaign=campaign
        local npcCount=0;for _ in pairs(campaign.npcs or {}) do npcCount=npcCount+1 end
        local templateCount=0;for _ in pairs(campaign.encounterTemplates or {}) do templateCount=templateCount+1 end
        local row=CreateFrame("Frame",nil,content,"BackdropTemplate");row:SetSize(620,54);row:SetPoint("TOPLEFT",0,-cursor);Backdrop(row,C.panel2,C.border,12)
        local title=Text(row,campaign.name or "DM Campaign",11,C.pale,DISPLAY);title:SetPoint("TOPLEFT",12,-8);title:SetWidth(440);title:SetWordWrap(false)
        local meta=Text(row,string.format("%d NPCs  •  %d encounter templates",npcCount,templateCount),8,C.gold);meta:SetPoint("TOPLEFT",12,-30);meta:SetWidth(440);meta:SetWordWrap(false)
        local share=Button(row,"Share",82,28,true);share:SetPoint("RIGHT",-10,0);share:SetScript("OnClick",function()
            CloseCalendarExchange(true);RPAL.OpenDMCampaignOnlineExchange(currentCampaign.id)
        end)
        shade._rpalExchangeChoiceButtons[#shade._rpalExchangeChoiceButtons+1]=share
        cursor=cursor+62
    end
    content:SetHeight(math.max(1,cursor+8))
    shade._rpalExchangeDirection="hub";shade._rpalExchangeEventCount=#events;shade._rpalExchangeArcCount=#arcs;shade._rpalExchangeCampaignCount=#campaigns
    return shade,f
end


local function ShortCommunityText(value,limit)
    local text=tostring(value or ""):gsub("%s+"," "):gsub("^%s+",""):gsub("%s+$","")
    limit=tonumber(limit) or 180
    if #text>limit then return text:sub(1,limit-1).."…" end
    return text
end

local function CommunitySourceLabel(event)
    local source=tostring(event and event.source or "Community")
    if event and event.recurring then return string.upper(source).."  •  RECURRING VENUE" end
    return string.upper(source)
end

local function CommunityDateLine(event)
    if not event then return "" end
    local stamp=time({year=tonumber(event.year) or 2000,month=tonumber(event.month) or 1,day=tonumber(event.day) or 1,hour=tonumber(event.hour) or 0,min=tonumber(event.minute) or 0,sec=0})
    local line=date("%A, %d %B %Y",stamp).."  •  "..TimeLabel(event)
    if tonumber(event.endHour) then line=line..string.format("–%02d:%02d",tonumber(event.endHour) or 0,tonumber(event.endMinute) or 0) end
    return line
end

local function ShowCommunityHover(event)
    if not event or not RPAL.ShowCursorTooltip then return end
    local meta={}
    if event.location and event.location~="" then meta[#meta+1]=event.location end
    if event.recurring then meta[#meta+1]="Recurring venue schedule" end
    RPAL.ShowCursorTooltip({
        eyebrow=CommunitySourceLabel(event),
        title=event.title or "Community Event",
        body=ShortCommunityText(event.note,170),
        sectionTitle="WHEN / WHERE",
        section=CommunityDateLine(event)..((#meta>0) and ("  •  "..table.concat(meta,"  •  ")) or ""),
        footer="Click for full details",
        accent=event.recurring and C.gold or C.green,
        width=390,
    })
end

local function CloseCommunityDetail()
    if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
    if communityDetail then communityDetail:Hide();communityDetail:SetParent(nil);communityDetail=nil end
end

local function OpenCommunityEventDetail(event)
    if not event then return end
    ApplyPalette();CloseCommunityDetail()
    local parent=(calendar and calendar:IsShown()) and calendar or UIParent
    local f=CreateFrame("Frame",nil,parent,"BackdropTemplate");communityDetail=f
    f:SetSize(690,500);f:SetPoint("CENTER",parent,"CENTER",0,0);Backdrop(f,C.bg,event.recurring and C.gold or C.green,18);Raise(f,9950,"TOOLTIP");f:EnableMouse(true);f:SetClampedToScreen(true)
    local accent=event.recurring and C.gold or C.green
    local stripe=f:CreateTexture(nil,"ARTWORK");stripe:SetColorTexture(accent[1],accent[2],accent[3],0.95);stripe:SetPoint("TOPLEFT",10,-12);stripe:SetPoint("BOTTOMLEFT",10,12);stripe:SetWidth(3)
    local eyebrow=Text(f,CommunitySourceLabel(event),8.5,accent);eyebrow:SetPoint("TOPLEFT",28,-22)
    local title=Text(f,event.title or "Community Event",22,C.pale,DISPLAY);title:SetPoint("TOPLEFT",28,-45);title:SetPoint("RIGHT",-64,0);title:SetJustifyH("LEFT");title:SetWordWrap(true)
    local close=Button(f,"×",34,28,false);close:SetPoint("TOPRIGHT",-16,-16);close.label:SetFont(DISPLAY,Scaled(17),"");close:SetScript("OnClick",CloseCommunityDetail)
    local rule=f:CreateTexture(nil,"ARTWORK");rule:SetColorTexture(accent[1],accent[2],accent[3],0.34);rule:SetPoint("TOPLEFT",28,-102);rule:SetPoint("TOPRIGHT",-28,-102);rule:SetHeight(1)
    local when=Text(f,CommunityDateLine(event),10.5,C.gold,DISPLAY);when:SetPoint("TOPLEFT",28,-118)
    local loc=(event.location and event.location~="") and event.location or "Location not specified"
    local where=Text(f,loc,10,C.pale);where:SetPoint("TOPLEFT",28,-143);where:SetPoint("RIGHT",-28,0);where:SetWordWrap(false)
    local host=(event.organizer and event.organizer~="") and ("Hosted / posted by "..event.organizer) or "Community-sourced event"
    local by=Text(f,host,8.5,C.muted);by:SetPoint("TOPLEFT",28,-166);by:SetPoint("RIGHT",-28,0)

    local descBox=CreateFrame("Frame",nil,f,"BackdropTemplate");descBox:SetPoint("TOPLEFT",28,-198);descBox:SetPoint("BOTTOMRIGHT",-28,72);Backdrop(descBox,C.panel,C.border,12)
    local scroll=CreateFrame("ScrollFrame",nil,descBox,"UIPanelScrollFrameTemplate");scroll:SetPoint("TOPLEFT",12,-12);scroll:SetPoint("BOTTOMRIGHT",-30,12)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    local child=CreateFrame("Frame",nil,scroll);child:SetSize(600,1);scroll:SetScrollChild(child)
    local body=Text(child,(event.note and event.note~="") and event.note or "No description was provided by the source.",10,C.pale);body:SetPoint("TOPLEFT",0,0);body:SetWidth(585);body:SetJustifyH("LEFT");body:SetJustifyV("TOP");body:SetWordWrap(true)
    local h=math.max(90,body:GetStringHeight()+16);child:SetHeight(h)

    local source=Text(f,"SOURCE  •  "..tostring(event.source or "Community"),8,C.muted);source:SetPoint("BOTTOMLEFT",28,54)
    local url=tostring(event.sourceUrl or "")
    if url~="" then
        local urlText=Text(f,ShortCommunityText(url,78),7.5,C.muted);urlText:SetPoint("BOTTOMLEFT",28,35);urlText:SetWidth(430);urlText:SetJustifyH("LEFT")
    end
    f:Show();if f.Raise then f:Raise() end
end

local function CloseHuginnBrowser()
    if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
    if huginnBrowser then huginnBrowser:Hide();huginnBrowser:SetParent(nil);huginnBrowser=nil end
end

local function OpenHuginnBrowser()
    ApplyPalette();CloseHuginnBrowser()
    local parent=(calendar and calendar:IsShown()) and calendar or UIParent
    local f=CreateFrame("Frame",nil,parent,"BackdropTemplate");huginnBrowser=f
    f:SetSize(850,590);f:SetPoint("CENTER",parent,"CENTER",0,0);Backdrop(f,C.bg,C.gold,18);Raise(f,9940,"TOOLTIP");f:EnableMouse(true);f:SetClampedToScreen(true)
    local title=Text(f,"HUGINN EVENTS",24,C.gold,DISPLAY);title:SetPoint("TOPLEFT",26,-22)
    local subtitle=Text(f,"Community events and recurring RP venues synchronized by the Huginn desktop companion.",9.5,C.muted);subtitle:SetPoint("TOPLEFT",28,-53);subtitle:SetPoint("RIGHT",-120,0);subtitle:SetWordWrap(false)
    local close=Button(f,"CLOSE",70,28,false);close:SetPoint("TOPRIGHT",-18,-18);close:SetScript("OnClick",CloseHuginnBrowser)
    local connected,count,syncedAt=false,0,0
    if RPAL.GetHuginnCommunityFeedStatus then connected,count,syncedAt=RPAL.GetHuginnCommunityFeedStatus() end
    local statusText=connected and ((tostring(count).." upcoming entries")..((syncedAt and syncedAt>0) and ("  •  synced "..date("%d %b %H:%M",syncedAt)) or "")) or "Huginn bridge not detected"
    local status=Text(f,statusText,8.5,connected and C.green or C.red);status:SetPoint("TOPLEFT",28,-76)
    local rule=f:CreateTexture(nil,"ARTWORK");rule:SetColorTexture(C.border[1],C.border[2],C.border[3],0.75);rule:SetPoint("TOPLEFT",26,-99);rule:SetPoint("TOPRIGHT",-26,-99);rule:SetHeight(1)

    if not connected then
        local box=CreateFrame("Frame",nil,f,"BackdropTemplate");box:SetPoint("TOPLEFT",44,-142);box:SetPoint("BOTTOMRIGHT",-44,44);Backdrop(box,C.panel,C.border,16)
        local icon=box:CreateTexture(nil,"ARTWORK");icon:SetTexture(CAL_ICON);icon:SetSize(54,54);icon:SetPoint("TOP",0,-42);icon:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],1)
        local h=Text(box,"HUGINN IS NOT CONNECTED",18,C.pale,DISPLAY);h:SetPoint("TOP",0,-112)
        local copy=Text(box,"Huginn is ODYN's optional lightweight desktop companion. It synchronizes public Argent Dawn events into this Calendar, provides desktop event reminders, and can sync ODYN + Total RP 3 data between your PCs.",10,C.muted);copy:SetPoint("TOP",h,"BOTTOM",0,-18);copy:SetWidth(620);copy:SetJustifyH("CENTER");copy:SetWordWrap(true)
        local steps=Text(box,"Install Huginn → run Community Sync → return to WoW and /reload.",10,C.gold,DISPLAY);steps:SetPoint("TOP",copy,"BOTTOM",0,-26)
        local foot=Text(box,"ODYN works normally without Huginn; this page simply remains offline.",8.5,C.muted);foot:SetPoint("TOP",steps,"BOTTOM",0,-14)
        f:Show();return
    end

    local scroll=CreateFrame("ScrollFrame",nil,f,"UIPanelScrollFrameTemplate");scroll:SetPoint("TOPLEFT",24,-116);scroll:SetPoint("BOTTOMRIGHT",-40,24)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    local child=CreateFrame("Frame",nil,scroll);child:SetSize(775,1);scroll:SetScrollChild(child)
    local events=RPAL.GetCommunityCalendarEvents and RPAL.GetCommunityCalendarEvents() or {}
    if #events==0 then
        local empty=Text(child,"Huginn is connected, but the latest feed contains no upcoming events.",11,C.muted,DISPLAY);empty:SetPoint("TOP",0,-50);empty:SetWidth(700);empty:SetJustifyH("CENTER");child:SetHeight(180)
    else
        local y=0;local lastDate=""
        for _,event in ipairs(events) do
            local dateKey=string.format("%04d-%02d-%02d",tonumber(event.year) or 0,tonumber(event.month) or 0,tonumber(event.day) or 0)
            if dateKey~=lastDate then
                lastDate=dateKey
                local stamp=time({year=event.year,month=event.month,day=event.day,hour=12,min=0,sec=0})
                local dh=Text(child,date("%A  •  %d %B %Y",stamp),9.5,C.gold,DISPLAY);dh:SetPoint("TOPLEFT",8,-y);dh:SetWidth(700)
                local rr=child:CreateTexture(nil,"ARTWORK");rr:SetColorTexture(C.border[1],C.border[2],C.border[3],0.55);rr:SetPoint("TOPLEFT",8,-y-22);rr:SetPoint("TOPRIGHT",-8,-y-22);rr:SetHeight(1)
                y=y+36
            end
            local current=event
            local row=CreateFrame("Button",nil,child,"BackdropTemplate");row:SetSize(755,86);row:SetPoint("TOPLEFT",8,-y);Backdrop(row,C.panel2,C.border,12)
            local accent=current.recurring and C.gold or C.green
            local bar=row:CreateTexture(nil,"ARTWORK");bar:SetColorTexture(accent[1],accent[2],accent[3],0.95);bar:SetPoint("TOPLEFT",3,-4);bar:SetPoint("BOTTOMLEFT",3,4);bar:SetWidth(3)
            local tm=Text(row,TimeLabel(current),16,accent,DISPLAY);tm:SetPoint("LEFT",14,6);tm:SetWidth(66);tm:SetJustifyH("CENTER")
            if tonumber(current.endHour) then local endt=Text(row,string.format("→ %02d:%02d",tonumber(current.endHour) or 0,tonumber(current.endMinute) or 0),7.5,C.muted);endt:SetPoint("TOP",tm,"BOTTOM",0,-2) end
            local st=Text(row,CommunitySourceLabel(current),7.2,accent);st:SetPoint("TOPLEFT",92,-10);st:SetPoint("RIGHT",-10,0)
            local tt=Text(row,current.title or "Community Event",11,C.pale,DISPLAY);tt:SetPoint("TOPLEFT",92,-28);tt:SetPoint("RIGHT",-18,0);tt:SetWordWrap(false)
            local meta=(current.location and current.location~="") and current.location or "Location not specified"
            local mm=Text(row,meta,8,C.muted);mm:SetPoint("TOPLEFT",92,-50);mm:SetPoint("RIGHT",-18,0);mm:SetWordWrap(false)
            row:SetScript("OnEnter",function(self)self:SetBackdropBorderColor(accent[1],accent[2],accent[3],0.95);ShowCommunityHover(current)end)
            row:SetScript("OnLeave",function(self)self:SetBackdropBorderColor(C.border[1],C.border[2],C.border[3],1);if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end end)
            row:SetScript("OnClick",function()OpenCommunityEventDetail(current)end)
            y=y+96
        end
        child:SetHeight(math.max(1,y+8))
    end
    f:Show();if f.Raise then f:Raise() end
end

local function EnsureCalendar()
    ApplyPalette()
    if calendar then return calendar end

    local shade=CreateFrame("Frame","RPAL_CalendarShade",UIParent,"BackdropTemplate")
    calendarShade=shade
    shade:SetAllPoints(UIParent);shade:EnableMouse(true);Raise(shade,9320)
    shade:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8"});shade:SetBackdropColor(0,0,0,0.46);shade:Hide()
    table.insert(UISpecialFrames,"RPAL_CalendarShade")

    local f=CreateFrame("Frame","RPAL_Calendar",shade,"BackdropTemplate")
    calendar=f
    f:SetSize(1010,700);f:SetPoint("CENTER");Raise(f,9360);Backdrop(f,C.bg,C.gold,18)
    local cover=f:CreateTexture(nil,"BACKGROUND",nil,-3);cover:SetTexture(TB.."UI_TravelBook_BookCover01a.tga");cover:SetAllPoints();cover:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],1);cover:SetAlpha(0.10);f.cover=cover
    local leftPage=f:CreateTexture(nil,"BACKGROUND",nil,-2);leftPage:SetTexture(TB.."UI_TravelBook_BookPageLeft01a.tga");leftPage:SetPoint("TOPLEFT",16,-112);leftPage:SetPoint("BOTTOM",f,"BOTTOM",-170,24);leftPage:SetWidth(600);leftPage:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],1);leftPage:SetAlpha(0.15);f.leftPage=leftPage
    local rightPage=f:CreateTexture(nil,"BACKGROUND",nil,-2);rightPage:SetTexture(TB.."UI_TravelBook_BookPageRight01a.tga");rightPage:SetPoint("TOPRIGHT",-16,-112);rightPage:SetPoint("BOTTOM",f,"BOTTOM",305,24);rightPage:SetWidth(350);rightPage:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],1);rightPage:SetAlpha(0.13);f.rightPage=rightPage
    if RPAL.GetSettings then f:SetScale(math.max(0.70,math.min(1.35,tonumber(RPAL.GetSettings().uiScale) or 1))) end
    f:SetClampedToScreen(true)

    local glow=f:CreateTexture(nil,"BACKGROUND",nil,-1);glow:SetTexture(SPARK);glow:SetBlendMode("ADD");glow:SetSize(560,210);glow:SetPoint("TOP",0,30);glow:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0.08)

    f.title=Text(f,"CALENDAR",26,C.gold,DISPLAY);f.title:SetPoint("TOPLEFT",26,-20)
    f.subtitle=Text(f,"Plan an RP date in two clicks. ODYN remembers the rest.",11,C.muted);f.subtitle:SetPoint("TOPLEFT",27,-52)
    f.close=Button(f,"×",34,28,false);f.close:SetPoint("TOPRIGHT",-16,-14);f.close.label:SetFont(DISPLAY,Scaled(18),"");f.close:SetScript("OnClick",function()shade:Hide()end)

    f.prev=Button(f,"‹",36,30,false);f.prev:SetPoint("TOPLEFT",26,-88);f.prev.label:SetFont(DISPLAY,Scaled(18),"")
    f.today=Button(f,"Today",64,30,false);f.today:SetPoint("LEFT",f.prev,"RIGHT",7,0)
    f.next=Button(f,"›",36,30,false);f.next:SetPoint("LEFT",f.today,"RIGHT",7,0);f.next.label:SetFont(DISPLAY,Scaled(18),"")
    f.monthTitle=Text(f,"",20,C.pale,DISPLAY);f.monthTitle:SetPoint("LEFT",f.next,"RIGHT",18,0)

    f.reminders=Button(f,"Reminders: ON",112,30,false);f.reminders:SetPoint("TOPRIGHT",-26,-88)
    f.exchange=Button(f,"Exchange",82,30,false);f.exchange:SetPoint("RIGHT",f.reminders,"LEFT",-7,0);f.exchange:SetScript("OnClick",function()RPAL.OpenExchangeHub()end)
    f.huginn=Button(f,"Huginn Events",108,30,false);f.huginn:SetPoint("RIGHT",f.exchange,"LEFT",-7,0);f.huginn:SetScript("OnClick",OpenHuginnBrowser)

    f.weekdays={}
    local labels={"MON","TUE","WED","THU","FRI","SAT","SUN"}
    for i,label in ipairs(labels) do
        local t=Text(f,label,9,C.muted);t:SetPoint("TOPLEFT",30+((i-1)*84),-137);t:SetWidth(78);t:SetJustifyH("CENTER");f.weekdays[i]=t
    end

    f.days={}
    for i=1,42 do
        local row=math.floor((i-1)/7)
        local col=(i-1)%7
        local b=CreateFrame("Button",nil,f,"BackdropTemplate")
        b:SetSize(78,66);b:SetPoint("TOPLEFT",30+(col*84),-158-(row*72));Backdrop(b,C.page,C.border,12)
        b.day=Text(b,"",14,C.pale,DISPLAY);b.day:SetPoint("TOPLEFT",9,-8)
        b.dot1=b:CreateTexture(nil,"ARTWORK");b.dot1:SetTexture(SPARK);b.dot1:SetBlendMode("ADD");b.dot1:SetSize(8,8);b.dot1:SetPoint("BOTTOMLEFT",8,7);b.dot1:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0)
        b.dot2=b:CreateTexture(nil,"ARTWORK");b.dot2:SetTexture(SPARK);b.dot2:SetBlendMode("ADD");b.dot2:SetSize(8,8);b.dot2:SetPoint("LEFT",b.dot1,"RIGHT",2,0);b.dot2:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0)
        b.dot3=b:CreateTexture(nil,"ARTWORK");b.dot3:SetTexture(SPARK);b.dot3:SetBlendMode("ADD");b.dot3:SetSize(8,8);b.dot3:SetPoint("LEFT",b.dot2,"RIGHT",2,0);b.dot3:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0)
        b.count=Text(b,"",8,C.gold);b.count:SetPoint("BOTTOMRIGHT",-7,7)
        b:SetScript("OnEnter",function(self)
            if self._day then self:SetBackdropBorderColor(C.gold[1],C.gold[2],C.gold[3],0.92) end
        end)
        b:SetScript("OnLeave",function(self)
            if not self._day then return end
            if self._isSelected then
                self:SetBackdropBorderColor(C.gold[1],C.gold[2],C.gold[3],0.98)
            elseif self._isToday then
                self:SetBackdropBorderColor(C.green[1],C.green[2],C.green[3],0.82)
            else
                self:SetBackdropBorderColor(C.border[1],C.border[2],C.border[3],0.82)
            end
        end)
        b:SetScript("OnClick",function(self)
            if not self._day then return end
            selectedDay=self._day
            RPAL.RefreshCalendar()
        end)
        f.days[i]=b
    end

    f.side=CreateFrame("Frame",nil,f,"BackdropTemplate");f.side:SetPoint("TOPLEFT",630,-132);f.side:SetPoint("BOTTOMRIGHT",-26,28);Backdrop(f.side,C.panel,C.border,14)
    f.sideTitle=Text(f.side,"",15,C.gold,DISPLAY)
    f.sideHint=Text(f.side,"Personal plans + Huginn community feed.",9,C.muted);f.sideHint:SetPoint("TOPLEFT",16,-42)
    f.add=Button(f.side,"+ Add Event",104,30,true);f.add:SetPoint("TOPRIGHT",-14,-14)
    -- Keep the date title out from under the Add Event button at every UI scale.
    f.sideTitle:SetPoint("TOPLEFT",16,-16);f.sideTitle:SetPoint("RIGHT",f.add,"LEFT",-10,0);f.sideTitle:SetJustifyH("LEFT");f.sideTitle:SetWordWrap(false)
    f.filterAll=Button(f.side,"ALL",58,24,false);f.filterAll:SetPoint("TOPLEFT",14,-64)
    f.filterPersonal=Button(f.side,"MINE",62,24,false);f.filterPersonal:SetPoint("LEFT",f.filterAll,"RIGHT",5,0)
    f.filterCommunity=Button(f.side,"COMMUNITY",96,24,false);f.filterCommunity:SetPoint("LEFT",f.filterPersonal,"RIGHT",5,0)
    f.eventScroll=CreateFrame("ScrollFrame",nil,f.side,"UIPanelScrollFrameTemplate");f.eventScroll:SetPoint("TOPLEFT",12,-100);f.eventScroll:SetPoint("BOTTOMRIGHT",-30,14)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(f.eventScroll) end
    f.eventContent=CreateFrame("Frame",nil,f.eventScroll);f.eventContent:SetSize(320,1);f.eventScroll:SetScrollChild(f.eventContent)

    f.prev:SetScript("OnClick",function()
        selectedMonth=selectedMonth-1;if selectedMonth<1 then selectedMonth=12;selectedYear=selectedYear-1 end
        selectedDay=math.min(selectedDay or 1,DaysInMonth(selectedYear,selectedMonth));RPAL.RefreshCalendar()
    end)
    f.next:SetScript("OnClick",function()
        selectedMonth=selectedMonth+1;if selectedMonth>12 then selectedMonth=1;selectedYear=selectedYear+1 end
        selectedDay=math.min(selectedDay or 1,DaysInMonth(selectedYear,selectedMonth));RPAL.RefreshCalendar()
    end)
    f.today:SetScript("OnClick",function()
        local now=date("*t");selectedYear,selectedMonth,selectedDay=now.year,now.month,now.day;RPAL.RefreshCalendar()
    end)
    f.reminders:SetScript("OnClick",function()
        RPAL.SetCalendarRemindersEnabled(not RPAL.GetCalendarRemindersEnabled());RPAL.RefreshCalendar()
    end)
    f.add:SetScript("OnClick",function()OpenEventEditor(selectedYear,selectedMonth,selectedDay,nil)end)
    f.filterAll:SetScript("OnClick",function()calendarSourceMode="all";RPAL.RefreshCalendar()end)
    f.filterPersonal:SetScript("OnClick",function()calendarSourceMode="personal";RPAL.RefreshCalendar()end)
    f.filterCommunity:SetScript("OnClick",function()calendarSourceMode="community";RPAL.RefreshCalendar()end)

    return f
end

function RPAL.RefreshCalendar()
    ApplyPalette()
    if not calendar or not calendar:IsShown() then return end
    local f=calendar
    Backdrop(f,C.bg,C.gold,18)
    if f.cover then f.cover:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],1) end
    if f.leftPage then f.leftPage:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],1) end
    if f.rightPage then f.rightPage:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],1) end
    if f.side then Backdrop(f.side,C.panel,C.border,14) end
    local now=date("*t")
    selectedYear=selectedYear or now.year;selectedMonth=selectedMonth or now.month;selectedDay=selectedDay or now.day

    f.monthTitle:SetText(date("%B %Y",time({year=selectedYear,month=selectedMonth,day=1,hour=12,min=0,sec=0})))
    f.reminders.label:SetText(RPAL.GetCalendarRemindersEnabled() and "Reminders: ON" or "Reminders: OFF")
    f.reminders.label:SetTextColor(unpack(RPAL.GetCalendarRemindersEnabled() and C.green or C.muted))
    local function FilterVisual(btn,active)
        Backdrop(btn,active and C.accent or C.button,active and C.gold or C.border,12)
        btn.label:SetTextColor(unpack(active and C.gold or C.pale))
    end
    FilterVisual(f.filterAll,calendarSourceMode=="all")
    FilterVisual(f.filterPersonal,calendarSourceMode=="personal")
    FilterVisual(f.filterCommunity,calendarSourceMode=="community")

    local first=FirstWeekdayMonday(selectedYear,selectedMonth)
    local total=DaysInMonth(selectedYear,selectedMonth)

    -- Build the visible event set once. The previous implementation queried and
    -- rebuilt the combined Huginn/personal list separately for every day cell.
    local displayEvents=(RPAL.GetCalendarDisplayEvents and RPAL.GetCalendarDisplayEvents(calendarSourceMode)) or {}
    local monthCounts={}
    for _,event in ipairs(displayEvents) do
        if tonumber(event.year)==selectedYear and tonumber(event.month)==selectedMonth then
            local d=tonumber(event.day)
            if d then monthCounts[d]=(monthCounts[d] or 0)+1 end
        end
    end

    for i,b in ipairs(f.days) do
        local day=i-first
        if day>=1 and day<=total then
            b._day=day;b:Show();b.day:SetText(tostring(day))
            local count=monthCounts[day] or 0
            b.dot1:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],count>=1 and 0.78 or 0)
            b.dot2:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],count>=2 and 0.78 or 0)
            b.dot3:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],count>=3 and 0.78 or 0)
            b.count:SetText(count>3 and ("+"..tostring(count-3)) or "")
            local isToday=(selectedYear==now.year and selectedMonth==now.month and day==now.day)
            local isSelected=(day==selectedDay)
            b._isToday=isToday;b._isSelected=isSelected
            if isSelected then
                local c=Mix(C.button,C.accent,0.62,0.98);b:SetBackdropColor(c[1],c[2],c[3],c[4]);b:SetBackdropBorderColor(C.gold[1],C.gold[2],C.gold[3],0.98)
                b.day:SetTextColor(C.gold[1],C.gold[2],C.gold[3],1)
            elseif isToday then
                b:SetBackdropColor(C.page[1],C.page[2],C.page[3],C.page[4]);b:SetBackdropBorderColor(C.green[1],C.green[2],C.green[3],0.82)
                b.day:SetTextColor(C.green[1],C.green[2],C.green[3],1)
            else
                b:SetBackdropColor(C.page[1],C.page[2],C.page[3],C.page[4]);b:SetBackdropBorderColor(C.border[1],C.border[2],C.border[3],0.82)
                b.day:SetTextColor(C.pale[1],C.pale[2],C.pale[3],1)
            end
        else
            b._day=nil;b._isToday=nil;b._isSelected=nil;b:Hide()
        end
    end

    f.sideTitle:SetText(DateLabel(selectedYear,selectedMonth,selectedDay))
    ClearChildren(f.eventContent)
    f.eventContent:SetSize(320,1)
    local events={}
    for _,event in ipairs(displayEvents) do
        if tonumber(event.year)==selectedYear and tonumber(event.month)==selectedMonth and tonumber(event.day)==selectedDay then
            events[#events+1]=event
        end
    end
    if #events==0 then
        local empty=Text(f.eventContent,"Nothing planned. Add an RP meeting, expedition, ceremony, tavern night — whatever this character needs to remember.",11,C.muted,DISPLAY)
        empty:SetPoint("TOP",0,-36);empty:SetWidth(285);empty:SetJustifyH("CENTER");empty:SetWordWrap(true)
        f.eventContent:SetHeight(170)
    else
        local y=0
        for _,event in ipairs(events) do
            local currentEvent=event
            local row=CreateFrame("Button",nil,f.eventContent,"BackdropTemplate");row:SetSize(305,70);row:SetPoint("TOPLEFT",0,-y);Backdrop(row,C.panel2,C.border,12)
            local timeText=Text(row,TimeLabel(event),16,C.gold,DISPLAY);timeText:SetPoint("LEFT",12,8);timeText:SetWidth(62);timeText:SetJustifyH("CENTER")
            local title=Text(row,event.title or "RP Event",11,C.pale,DISPLAY);title:SetPoint("TOPLEFT",78,-12);title:SetPoint("RIGHT",-96,0);title:SetWordWrap(false)
            local note=tostring(event.note or "");if note=="" then note="No note." end
            local body=Text(row,note,8.5,C.muted);body:SetPoint("TOPLEFT",78,-34);body:SetPoint("RIGHT",-96,0);body:SetWordWrap(false)
            if currentEvent._community then
                local accent=currentEvent.recurring and C.gold or C.green
                local badge=Text(row,currentEvent.recurring and "VENUE" or "HUGINN",7.2,accent);badge:SetPoint("TOPRIGHT",-10,-10)
                local source=Text(row,(currentEvent.location and currentEvent.location~="") and currentEvent.location or tostring(currentEvent.source or "Community"),7.2,C.gold);source:SetPoint("BOTTOMRIGHT",-10,10);source:SetWidth(88);source:SetJustifyH("RIGHT")
                row:SetScript("OnClick",function()OpenCommunityEventDetail(currentEvent)end)
            else
                local del=Button(row,"×",28,26,false);del:SetPoint("RIGHT",-8,0);del.label:SetFont(DISPLAY,Scaled(15),"OUTLINE")
                del:SetScript("OnClick",function()RPAL.DeleteCalendarEvent(currentEvent.id)end)
                local share=Button(row,"Share",52,26,false);share:SetPoint("RIGHT",del,"LEFT",-4,0)
                share:SetScript("OnClick",function()if RPAL.OpenCalendarExchange then RPAL.OpenCalendarExchange(currentEvent) end end)
                row:SetScript("OnClick",function()OpenEventEditor(selectedYear,selectedMonth,selectedDay,currentEvent)end)
                row._rpalShareButton=share
            end
            row:SetScript("OnEnter",function(self)
                self:SetBackdropBorderColor(C.gold[1],C.gold[2],C.gold[3],0.84)
                if currentEvent._community then ShowCommunityHover(currentEvent) end
            end)
            row:SetScript("OnLeave",function(self)
                self:SetBackdropBorderColor(C.border[1],C.border[2],C.border[3],1)
                if currentEvent._community and RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
            end)
            y=y+78
        end
        f.eventContent:SetHeight(math.max(1,y))
    end
end

function RPAL.OpenCalendar(year,month,day)
    local now=date("*t")
    selectedYear=tonumber(year) or now.year
    selectedMonth=tonumber(month) or now.month
    selectedDay=tonumber(day) or now.day
    local f=EnsureCalendar()
    if RPAL.PrepareWindow then RPAL.PrepareWindow(calendarShade,nil,0.18) else calendarShade:SetAlpha(1);calendarShade:Show();if calendarShade.Raise then calendarShade:Raise() end end
    f:Show()
    if f.Raise then f:Raise() end
    RPAL.RefreshCalendar()
    return f
end

function RPAL.IsCalendarOpen()
    return calendarShade and calendarShade:IsShown()
end

function RPAL.ResetCalendarUI()
    CloseCommunityDetail();CloseHuginnBrowser()
    if eventEditorShade then eventEditorShade:Hide();eventEditorShade=nil end
    calendarExchangeQueue={};if calendarExchangeShade then CloseCalendarExchange(false) end
    if calendarShade then calendarShade:Hide();calendarShade:SetScript("OnUpdate",nil) end
    if reminderToast then reminderToast:Hide();reminderToast:SetScript("OnUpdate",nil) end
    reminderQueue={}
    selectedYear=nil;selectedMonth=nil;selectedDay=nil
end

local function EnsureReminderToast()
    ApplyPalette()
    if reminderToast then
        Backdrop(reminderToast,C.bg,C.gold,16)
        reminderToast.icon:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],1)
        return reminderToast
    end
    local f=CreateFrame("Frame","RPAL_CalendarReminder",UIParent,"BackdropTemplate")
    reminderToast=f
    f:SetSize(510,118);f:SetPoint("TOP",UIParent,"TOP",0,-116);Raise(f,9860);Backdrop(f,C.bg,C.gold,16);f:EnableMouse(false);f:Hide()
    f.icon=f:CreateTexture(nil,"ARTWORK");f.icon:SetTexture(CAL_ICON);f.icon:SetSize(38,38);f.icon:SetPoint("LEFT",18,0);f.icon:SetTexCoord(0.08,0.92,0.08,0.92);f.icon:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],1)
    f.eyebrow=Text(f,"CALENDAR",8,C.muted);f.eyebrow:SetPoint("TOPLEFT",70,-18)
    f.title=Text(f,"",16,C.pale,DISPLAY);f.title:SetPoint("TOPLEFT",70,-39);f.title:SetPoint("RIGHT",-18,0);f.title:SetWordWrap(false)
    f.meta=Text(f,"",9.5,C.gold);f.meta:SetPoint("TOPLEFT",70,-68)
    f.glow=f:CreateTexture(nil,"BACKGROUND",nil,-1);f.glow:SetTexture(SPARK);f.glow:SetBlendMode("ADD");f.glow:SetSize(320,110);f.glow:SetPoint("CENTER",0,0);f.glow:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0.12)
    return f
end

local function StartNextReminder()
    if reminderToast and reminderToast:IsShown() then return end
    if #reminderQueue==0 then return end
    local entry=table.remove(reminderQueue,1)
    local f=EnsureReminderToast()
    if RPAL.ApplyToastLayout then RPAL.ApplyToastLayout(f) end
    local event=entry.event or {}
    f.eyebrow:SetText(entry.kind=="hour" and "IN ONE HOUR" or "TODAY IN YOUR CALENDAR")
    f.title:SetText(event.title or "RP Event")
    f.meta:SetText(string.format("%02d:%02d",tonumber(event.hour) or 0,tonumber(event.minute) or 0)..((event.note and event.note~="") and ("  •  "..event.note) or ""))
    f:SetAlpha(0);f:Show()
    local elapsed=0
    f:SetScript("OnUpdate",function(self,dt)
        elapsed=elapsed+(dt or 0)
        local alpha
        if elapsed<0.32 then
            local p=elapsed/0.32;alpha=1-((1-p)*(1-p)*(1-p))
        elseif elapsed<5.3 then alpha=1
        elseif elapsed<6.1 then
            local p=(elapsed-5.3)/0.8;alpha=1-p
        else
            self:Hide();self:SetScript("OnUpdate",nil);StartNextReminder();return
        end
        self:SetAlpha(alpha)
        self.glow:SetAlpha((0.08+0.08*math.sin(elapsed*2.0))*alpha)
    end)
end

function RPAL.ShowCalendarReminder(event,kind)
    reminderQueue[#reminderQueue+1]={event=event,kind=kind}
    StartNextReminder()
end
