local ADDON_NAME, RPAL = ...

-- ============================================================
-- RPAL Calendar
-- Character-specific RP planning with lightweight reminders.
-- ============================================================

local MAX_EVENTS = 160
local reminderTicker
local CALENDAR_EXCHANGE_PREFIX="RPAL_CALX"
local CALENDAR_EXCHANGE_CHUNK=180
local CALENDAR_EXCHANGE_MAX_CHUNKS=160
local calendarExchangeIncoming={}
local calendarExchangeOffers={}
local calendarExchangeOutgoing={}

local function CalendarDB()
    if type(RPAL_CHAR_DB) ~= "table" then RPAL_CHAR_DB = {} end
    RPAL_CHAR_DB.calendar = RPAL_CHAR_DB.calendar or {}
    local db = RPAL_CHAR_DB.calendar
    db.events = type(db.events)=="table" and db.events or {}
    if db.remindersEnabled == nil then db.remindersEnabled = true end
    return db
end

local function ClampInt(v, lo, hi, fallback)
    v = math.floor(tonumber(v) or fallback or lo)
    if v < lo then return lo end
    if v > hi then return hi end
    return v
end

local function EventStamp(event)
    if not event then return nil end
    local year,month,day=tonumber(event.year),tonumber(event.month),tonumber(event.day)
    local hour,minute=tonumber(event.hour) or 0,tonumber(event.minute) or 0
    if not year or not month or not day or year~=year or month~=month or day~=day or hour~=hour or minute~=minute then return nil end
    if year%1~=0 or month%1~=0 or day%1~=0 or hour%1~=0 or minute%1~=0 then return nil end
    if year<2000 or year>2200 or month<1 or month>12 or day<1 or day>31 or hour<0 or hour>23 or minute<0 or minute>59 then return nil end
    local ok, result = pcall(time, {
        year = year,
        month = month,
        day = day,
        hour = hour,
        min = minute,
        sec = 0,
    })
    if not ok or type(result)~="number" then return nil end
    local roundTrip=date("*t",result)
    if not roundTrip or roundTrip.year~=year or roundTrip.month~=month or roundTrip.day~=day or roundTrip.hour~=hour or roundTrip.min~=minute then return nil end
    return result
end

local function SortEvents(events)
    table.sort(events, function(a,b)
        local ta = EventStamp(a) or 0
        local tb = EventStamp(b) or 0
        if ta == tb then return tostring(a.title or "") < tostring(b.title or "") end
        return ta < tb
    end)
end

function RPAL.GetCalendarData()
    return CalendarDB()
end

function RPAL.GetCalendarEventTimestamp(event)
    return EventStamp(event)
end

function RPAL.GetCalendarEvents()
    local db = CalendarDB()
    SortEvents(db.events)
    return db.events
end


-- Huginn Community Calendar -------------------------------------------------
-- Huginn writes a tiny read-only companion addon named HuginnBridge. ODYN
-- never edits that file and never needs internet access itself.
--
-- IMPORTANT: CalendarUI asks for event counts for every visible day. Parsing and
-- sorting the whole Huginn feed for each of those cells caused large frame-time
-- spikes once recurring venue entries were added. Keep one parsed/indexed cache
-- for the lifetime of the loaded bridge data instead.
local huginnCache={feed=nil,raw=nil,syncedAt=nil,revision=nil,count=-1,events={},byDate={}}

local function CalendarDateKey(year,month,day)
    return tostring(tonumber(year) or 0).."-"..tostring(tonumber(month) or 0).."-"..tostring(tonumber(day) or 0)
end

local function ParseHuginnLiteralClock(src)
    if type(src)~="table" then return nil,nil end
    -- Huginn sources are authored in realm/server time.  Desktop date APIs can
    -- silently normalize a parsed clock to UTC (for example 20:00 -> 18:00 in
    -- summer).  Prefer every preserved literal/server-side clock field before
    -- touching normalized numeric `hour` values.
    local h=tonumber(src.serverHour or src.originalHour or src.realmHour or src.literalHour or src.publishedHour)
    local m=tonumber(src.serverMinute or src.originalMinute or src.realmMinute or src.literalMinute or src.publishedMinute)
    if h and h>=0 and h<=23 then return math.floor(h),math.max(0,math.min(59,math.floor(m or 0))) end
    local raw=tostring(src.originalTime or src.serverTime or src.realmTime or src.literalTime or src.displayTime or src.timeText or src.startTimeText or src.publishedTime or "")
    local upper=string.upper(raw)
    local hh,mm,ampm=upper:match("(%d?%d)%s*:%s*(%d%d)%s*([AP]M)")
    hh,mm=tonumber(hh),tonumber(mm)
    if hh and mm and ampm and hh>=1 and hh<=12 and mm>=0 and mm<=59 then
        if ampm=="AM" then hh=(hh==12) and 0 or hh else hh=(hh==12) and 12 or (hh+12) end
        return hh,mm
    end
    hh,mm=raw:match("(%d?%d)%s*:%s*(%d%d)")
    hh,mm=tonumber(hh),tonumber(mm)
    if hh and mm and hh>=0 and hh<=23 and mm>=0 and mm<=59 then return hh,mm end
    local hourOnly,ampmOnly=upper:match("(%d?%d)%s*([AP]M)")
    hourOnly=tonumber(hourOnly)
    if hourOnly and ampmOnly and hourOnly>=1 and hourOnly<=12 then
        if ampmOnly=="AM" then hourOnly=(hourOnly==12) and 0 or hourOnly else hourOnly=(hourOnly==12) and 12 or (hourOnly+12) end
        return hourOnly,0
    end
    return nil,nil
end

local function ParseHuginnLiteralDate(src)
    if type(src)~="table" then return nil,nil,nil end
    local y=tonumber(src.serverYear or src.originalYear or src.realmYear or src.literalYear or src.publishedYear)
    local mo=tonumber(src.serverMonth or src.originalMonth or src.realmMonth or src.literalMonth or src.publishedMonth)
    local d=tonumber(src.serverDay or src.originalDay or src.realmDay or src.literalDay or src.publishedDay)
    if y and mo and d then return math.floor(y),math.floor(mo),math.floor(d) end
    local raw=tostring(src.originalDate or src.serverDate or src.realmDate or src.literalDate or src.displayDate or src.dateText or src.publishedDate or "")
    local yy,mn,dd=raw:match("(%d%d%d%d)[%-%./](%d%d?)[%-%./](%d%d?)")
    yy,mn,dd=tonumber(yy),tonumber(mn),tonumber(dd)
    if yy and mn and dd then return yy,mn,dd end
    return nil,nil,nil
end


local function IsLegacyHuginnCalendarCopy(event)
    if type(event)~="table" then return false end
    if event._huginnManaged==true or event.huginnManaged==true or event.huginnId~=nil then return true end
    local managed=tostring(event.managedBy or event.sourceType or event.provider or ""):lower()
    if managed=="huginn" or managed=="huginnbridge" then return true end
    local note=tostring(event.note or ""):lower()
    return note:find("imported from huginn",1,true)~=nil
end

local function PurgeLegacyHuginnCalendarCopies()
    -- Huginn events are now live, read-only feed entries.  Older builds could
    -- copy them into the character's personal Calendar via "Save to my
    -- calendar"; those snapshots never received later date/time corrections
    -- and are the source of stale 18:00 rows.  Once a live Huginn feed exists,
    -- retire only those machine-created copies. Player-created events have no
    -- Huginn marker and are deliberately never touched.
    local db=CalendarDB()
    local changed=false
    for index=#db.events,1,-1 do
        if IsLegacyHuginnCalendarCopy(db.events[index]) then
            table.remove(db.events,index)
            changed=true
        end
    end
    if changed then SortEvents(db.events) end
    return changed
end

local function RebuildHuginnCache()
    local feed=_G and _G.HUGINN_COMMUNITY_FEED
    local raw=type(feed)=="table" and feed.events or nil
    local syncedAt=type(feed)=="table" and tonumber(feed.syncedAt) or nil
    local revision=type(feed)=="table" and tostring(feed.revision or feed.feedRevision or feed.updatedAt or feed.generatedAt or feed.hash or "") or ""
    local count=type(raw)=="table" and #raw or 0
    if huginnCache.feed==feed and huginnCache.raw==raw and huginnCache.syncedAt==syncedAt and huginnCache.revision==revision and huginnCache.count==count then return end

    local out,byDate={},{}
    if type(raw)=="table" then
        for _,src in ipairs(raw) do
            if type(src)=="table" then
                local literalHour,literalMinute=ParseHuginnLiteralClock(src)
                local literalYear,literalMonth,literalDay=ParseHuginnLiteralDate(src)
                local e={
                    id=tostring(src.id or "huginn-community"),
                    title=tostring(src.title or "Community Event"),
                    note=tostring(src.note or ""),
                    year=literalYear or tonumber(src.year),month=literalMonth or tonumber(src.month),day=literalDay or tonumber(src.day),
                    hour=literalHour or tonumber(src.hour) or 20,minute=literalMinute or tonumber(src.minute) or 0,
                    location=tostring(src.location or ""),organizer=tostring(src.organizer or ""),
                    source=tostring(src.source or "Community"),sourceUrl=tostring(src.sourceUrl or ""),
                    originalTime=tostring(src.originalTime or ""),timeKnown=src.timeKnown~=false,
                    confidence=tonumber(src.confidence) or 0,
                    endHour=tonumber(src.serverEndHour or src.endHour),endMinute=tonumber(src.serverEndMinute or src.endMinute),
                    recurring=src.recurring==true,kind=tostring(src.kind or "Event"),
                    _community=true,_readOnly=true,
                }
                if EventStamp(e) then
                    out[#out+1]=e
                    local key=CalendarDateKey(e.year,e.month,e.day)
                    local bucket=byDate[key]
                    if not bucket then bucket={};byDate[key]=bucket end
                    bucket[#bucket+1]=e
                end
            end
        end
    end
    SortEvents(out)
    for _,bucket in pairs(byDate) do SortEvents(bucket) end
    huginnCache.feed,huginnCache.raw,huginnCache.syncedAt,huginnCache.revision,huginnCache.count=feed,raw,syncedAt,revision,count
    huginnCache.events,huginnCache.byDate=out,byDate

    -- Make the current bridge feed authoritative over old Huginn snapshots in
    -- the personal event table.  This means every sync can replace dates,
    -- clocks, locations and descriptions without ever rewriting a manual event.
    if type(feed)=="table" then PurgeLegacyHuginnCalendarCopies() end
end

local function HuginnCommunityEvents()
    RebuildHuginnCache()
    return huginnCache.events
end

local function HuginnCommunityEventsForDate(year,month,day)
    RebuildHuginnCache()
    return huginnCache.byDate[CalendarDateKey(year,month,day)] or {}
end

function RPAL.GetCommunityCalendarEvents()
    return HuginnCommunityEvents()
end

function RPAL.GetCalendarDisplayEvents(mode)
    mode=tostring(mode or "all"):lower()
    RebuildHuginnCache() -- also retires stale machine-created Huginn snapshots before personal rows are copied
    local out={}
    if mode~="community" then
        for _,event in ipairs(CalendarDB().events) do out[#out+1]=event end
    end
    if mode~="personal" then
        for _,event in ipairs(HuginnCommunityEvents()) do out[#out+1]=event end
    end
    SortEvents(out)
    return out
end

function RPAL.GetCalendarDisplayEventsForDate(year,month,day,mode)
    year,month,day=tonumber(year),tonumber(month),tonumber(day)
    mode=tostring(mode or "all"):lower()
    RebuildHuginnCache() -- make the live bridge authoritative before collecting day rows
    local out={}
    if mode~="community" then
        for _,event in ipairs(CalendarDB().events) do
            if tonumber(event.year)==year and tonumber(event.month)==month and tonumber(event.day)==day then out[#out+1]=event end
        end
    end
    if mode~="personal" then
        for _,event in ipairs(HuginnCommunityEventsForDate(year,month,day)) do out[#out+1]=event end
    end
    if #out>1 then SortEvents(out) end
    return out
end

function RPAL.GetHuginnCommunityFeedStatus()
    local feed=_G and _G.HUGINN_COMMUNITY_FEED
    if type(feed)~="table" then return false,0,0 end
    return true,#(type(feed.events)=="table" and feed.events or {}),tonumber(feed.syncedAt) or 0
end

function RPAL.GetCalendarEventsForDate(year, month, day)
    year,month,day = tonumber(year),tonumber(month),tonumber(day)
    local out = {}
    for _,event in ipairs(CalendarDB().events) do
        if tonumber(event.year)==year and tonumber(event.month)==month and tonumber(event.day)==day then
            out[#out+1] = event
        end
    end
    SortEvents(out)
    return out
end

function RPAL.GetTodayCalendarEventCount()
    local now = date("*t")
    local events = RPAL.GetCalendarEventsForDate(now.year,now.month,now.day)
    return #events
end

function RPAL.AddCalendarEvent(year,month,day,hour,minute,title,note)
    title = tostring(title or ""):gsub("^%s+",""):gsub("%s+$","")
    if title=="" then return false,"Give the event a name." end

    year = ClampInt(year,2000,2200,date("*t").year)
    month = ClampInt(month,1,12,date("*t").month)
    day = ClampInt(day,1,31,date("*t").day)
    hour = ClampInt(hour,0,23,20)
    minute = ClampInt(minute,0,59,0)

    local event = {
        id = (RPAL.NewID and RPAL.NewID("calendar")) or ("cal-"..tostring(time()).."-"..tostring(math.random(1000,9999))),
        title = title,
        note = tostring(note or ""),
        year = year, month = month, day = day,
        hour = hour, minute = minute,
        createdAt = time(),
        dayReminderSent = false,
        hourReminderSent = false,
    }

    if not EventStamp(event) then return false,"That date is not valid." end

    local db = CalendarDB()
    table.insert(db.events,event)
    SortEvents(db.events)
    while #db.events > MAX_EVENTS do table.remove(db.events,1) end

    if RPAL.RefreshCalendar then RPAL.RefreshCalendar() end
    if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome() end
    return true,event
end

function RPAL.AddCalendarEventFromTimestamp(timestamp,title,note)
    local d = date("*t",tonumber(timestamp) or time())
    return RPAL.AddCalendarEvent(d.year,d.month,d.day,d.hour,d.min,title,note)
end

function RPAL.UpdateCalendarEvent(id,changes)
    changes = type(changes)=="table" and changes or {}
    local db=CalendarDB()
    for _,event in ipairs(db.events) do
        if event.id==id then
            local oldStamp = EventStamp(event)
            local candidate={}
            for key,value in pairs(event) do candidate[key]=value end
            for _,key in ipairs({"title","note","year","month","day","hour","minute"}) do
                if changes[key]~=nil then candidate[key]=changes[key] end
            end
            candidate.title=tostring(candidate.title or ""):gsub("^%s+",""):gsub("%s+$","")
            if candidate.title=="" then return false,"Give the event a name." end
            candidate.note=tostring(candidate.note or "")
            candidate.year=ClampInt(candidate.year,2000,2200,date("*t").year)
            candidate.month=ClampInt(candidate.month,1,12,date("*t").month)
            candidate.day=ClampInt(candidate.day,1,31,date("*t").day)
            candidate.hour=ClampInt(candidate.hour,0,23,20)
            candidate.minute=ClampInt(candidate.minute,0,59,0)
            local newStamp=EventStamp(candidate)
            if not newStamp then return false,"That date is not valid." end
            for _,key in ipairs({"title","note","year","month","day","hour","minute"}) do event[key]=candidate[key] end
            if newStamp~=oldStamp then
                event.dayReminderSent=false
                event.hourReminderSent=false
            end
            SortEvents(db.events)
            if RPAL.RefreshCalendar then RPAL.RefreshCalendar() end
            if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome() end
            return true,event
        end
    end
    return false,"Event not found."
end

function RPAL.DeleteCalendarEvent(id)
    local db=CalendarDB()
    for i,event in ipairs(db.events) do
        if event.id==id then
            table.remove(db.events,i)
            if RPAL.RefreshCalendar then RPAL.RefreshCalendar() end
            if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome() end
            return true
        end
    end
    return false
end

function RPAL.SetCalendarRemindersEnabled(enabled)
    CalendarDB().remindersEnabled = enabled and true or false
end

function RPAL.GetCalendarRemindersEnabled()
    return CalendarDB().remindersEnabled ~= false
end

function RPAL.GetUpcomingCalendarEvents(seconds)
    seconds=math.max(0,tonumber(seconds) or 86400)
    local now=time()
    local out={}
    for _,event in ipairs(CalendarDB().events) do
        local stamp=EventStamp(event)
        if stamp and stamp>=now and stamp<=now+seconds then out[#out+1]=event end
    end
    SortEvents(out)
    return out
end

function RPAL.CheckCalendarReminders()
    local db=CalendarDB()
    if db.remindersEnabled==false then return end

    local now=time()
    local today=date("%Y-%m-%d",now)

    for _,event in ipairs(db.events) do
        local stamp=EventStamp(event)
        if stamp then
            local eventDay=date("%Y-%m-%d",stamp)

            local remaining=stamp-now
            if eventDay==today and not event.dayReminderSent then
                event.dayReminderSent=true
                -- Inside the final hour the stronger one-hour reminder is
                -- enough; do not queue two notices back-to-back on login.
                if remaining>3600 then
                    if RPAL.ShowCalendarReminder then
                        RPAL.ShowCalendarReminder(event,"day")
                    else
                        print("|cffd9a644ODYN Calendar:|r "..tostring(event.title or "Event").." is today.")
                    end
                end
            end

            if remaining>=0 and remaining<=3600 and not event.hourReminderSent then
                event.hourReminderSent=true
                if RPAL.ShowCalendarReminder then
                    RPAL.ShowCalendarReminder(event,"hour")
                else
                    print("|cffd9a644ODYN Calendar:|r "..tostring(event.title or "Event").." begins within one hour.")
                end
            end
        end
    end
end


-- ------------------------------------------------------------
-- Consensual player-to-player Calendar Exchange
-- ------------------------------------------------------------
-- This protocol only knows the RPALCAL1 event schema.  It cannot carry or
-- import character, achievement, Renown or customization SavedVariables.
local function TransferText(value,maximum)
    value=tostring(value or ""):gsub("\r\n","\n"):gsub("\r","\n"):gsub("[%z\1-\8\11\12\14-\31]","")
    -- Calendar Exchange text is plain text. Strip WoW hyperlink, texture,
    -- atlas and color escapes so a sender cannot disguise the approval card.
    value=value:gsub("|c%x%x%x%x%x%x%x%x",""):gsub("|r","")
    value=value:gsub("|H.-|h(.-)|h","%1"):gsub("|T.-|t",""):gsub("|A.-|a",""):gsub("|","")
    maximum=math.max(0,math.floor(tonumber(maximum) or 0))
    if #value<=maximum then return value end
    local start=maximum
    while start>0 do
        local byte=string.byte(value,start)
        if byte and byte>=0x80 and byte<=0xBF then start=start-1 else break end
    end
    local lead=string.byte(value,start)
    local length=(lead and lead>=0xF0 and lead<=0xF7 and 4)
        or (lead and lead>=0xE0 and lead<=0xEF and 3)
        or (lead and lead>=0xC0 and lead<=0xDF and 2) or 1
    local cut=(start+length-1<=maximum) and maximum or (start-1)
    return value:sub(1,math.max(0,cut))
end

local function HexEncode(value)
    return (tostring(value or ""):gsub(".",function(character)return string.format("%02X",string.byte(character))end))
end

local function HexDecode(value,maximum)
    value=tostring(value or "")
    if #value%2~=0 or value:find("[^%x]") or (maximum and #value>(maximum*2)) then return nil end
    local out={}
    for index=1,#value,2 do out[#out+1]=string.char(tonumber(value:sub(index,index+1),16)) end
    return table.concat(out)
end

function RPAL.MakeCalendarExchangeEvent(event)
    if type(event)~="table" or not EventStamp(event) then return nil,"That Calendar event is not valid." end
    local copy={
        title=TransferText(event.title,160),note=TransferText(event.note,1800),
        year=tonumber(event.year),month=tonumber(event.month),day=tonumber(event.day),
        hour=tonumber(event.hour) or 0,minute=tonumber(event.minute) or 0,
    }
    if copy.title=="" then return nil,"That Calendar event has no title." end
    return copy
end

function RPAL.ExportCalendarExchangeEvent(event)
    local copy,err=RPAL.MakeCalendarExchangeEvent(event)
    if not copy then return nil,err end
    return table.concat({
        "RPALCAL1",tostring(copy.year),tostring(copy.month),tostring(copy.day),
        tostring(copy.hour),tostring(copy.minute),HexEncode(copy.title),HexEncode(copy.note),
    },";")
end

function RPAL.ImportCalendarExchangeEvent(payload)
    payload=tostring(payload or "")
    if #payload>8192 then return nil,"Calendar Exchange payload is too large." end
    local fields={};local start=1
    while true do
        local at=payload:find(";",start,true)
        if not at then fields[#fields+1]=payload:sub(start);break end
        fields[#fields+1]=payload:sub(start,at-1);start=at+1
    end
    if #fields~=8 or fields[1]~="RPALCAL1" then return nil,"Calendar Exchange payload is malformed." end
    local event={
        year=tonumber(fields[2]),month=tonumber(fields[3]),day=tonumber(fields[4]),
        hour=tonumber(fields[5]),minute=tonumber(fields[6]),
        title=HexDecode(fields[7],160),note=HexDecode(fields[8],1800),
    }
    if not event.title or not event.note or event.title=="" or not EventStamp(event) then return nil,"Calendar Exchange event is invalid." end
    return event
end

local function CleanPlayerName(value)
    value=tostring(value or ""):gsub("|c%x%x%x%x%x%x%x%x",""):gsub("|r","")
    value=value:gsub("[%z\1-\31]",""):gsub("^%s+",""):gsub("%s+$","")
    if value=="" or #value>96 or value:find("[;|]") then return nil end
    return value
end

local function ExchangeSend(target,message)
    target=CleanPlayerName(target)
    if not target or not C_ChatInfo or type(C_ChatInfo.SendAddonMessage)~="function" then return false end
    if RPAL.SendAddonMessageSafe then return RPAL.SendAddonMessageSafe(CALENDAR_EXCHANGE_PREFIX,message,"WHISPER",target,true) end
    local ok,result=pcall(C_ChatInfo.SendAddonMessage,CALENDAR_EXCHANGE_PREFIX,message,"WHISPER",target)
    return ok and (result==nil or result==true or tonumber(result)==0)
end

local function NotifyExchangeFailure(record,reason)
    if not record then return end
    if record.assetType=="A" and RPAL.OnStoryArcExchangeResult then RPAL.OnStoryArcExchangeResult("E",record.target,record.preview,reason)
    elseif record.assetType=="D" and RPAL.OnDMCampaignExchangeResult then RPAL.OnDMCampaignExchangeResult("E",record.target,record.preview,reason)
    elseif RPAL.OnCalendarExchangeResult then RPAL.OnCalendarExchangeResult("E",record.target,record.preview,reason) end
end

local function SendExchangePayload(assetType,payload,target,preview)
    target=CleanPlayerName(target)
    if not target then return false,"Enter a valid player name." end
    assetType=(assetType=="A" or assetType=="D") and assetType or "C"
    payload=tostring(payload or "")
    local count=math.ceil(#payload/CALENDAR_EXCHANGE_CHUNK)
    if count<1 or count>CALENDAR_EXCHANGE_MAX_CHUNKS then
        if assetType=="A" then return false,"That Story Arc is too large for Online Exchange. Use its export code instead." end
        if assetType=="D" then return false,"That DM Campaign is too large for Online Exchange. Use its export string instead." end
        return false,"That Calendar event is too large to send."
    end
    local transferID=((assetType=="A" and "arcx-") or (assetType=="D" and "dmcx-") or "calx-")..tostring(time()).."-"..tostring(math.random(100000,999999))
    if not ExchangeSend(target,table.concat({"O",transferID,assetType,tostring(count)},";")) then return false,"Online Exchange could not reach that player." end
    calendarExchangeOutgoing[transferID]={target=target,assetType=assetType,preview=preview,sentAt=time()}
    local function SendChunk(index)
        local chunk=payload:sub(((index-1)*CALENDAR_EXCHANGE_CHUNK)+1,index*CALENDAR_EXCHANGE_CHUNK)
        return ExchangeSend(target,table.concat({"D",transferID,tostring(index),chunk},";"))
    end
    if count>8 and C_Timer and C_Timer.NewTicker then
        local index=1
        local ticker
        ticker=C_Timer.NewTicker(0.045,function()
            if not SendChunk(index) then
                if ticker and ticker.Cancel then ticker:Cancel() end
                local record=calendarExchangeOutgoing[transferID];calendarExchangeOutgoing[transferID]=nil
                NotifyExchangeFailure(record,"transfer interrupted")
                return
            end
            index=index+1
            if index>count and ticker and ticker.Cancel then ticker:Cancel() end
        end)
    else
        for index=1,count do
            if not SendChunk(index) then calendarExchangeOutgoing[transferID]=nil;return false,"Online Exchange was interrupted." end
        end
    end
    if C_Timer and C_Timer.After then C_Timer.After(90,function()calendarExchangeOutgoing[transferID]=nil end) end
    return true,transferID
end

function RPAL.SendCalendarEventExchange(event,target)
    local payload,err=RPAL.ExportCalendarExchangeEvent(event)
    if not payload then return false,err end
    local preview=RPAL.MakeCalendarExchangeEvent(event)
    return SendExchangePayload("C",payload,target,preview)
end

function RPAL.SendStoryArcExchange(arcOrID,target)
    if not RPAL.ExportStoryArc or not RPAL.GetStoryArcExchangePreview then return false,"Story Arc Exchange is not available." end
    local payload,err=RPAL.ExportStoryArc(arcOrID)
    if not payload then return false,err end
    local preview,previewError=RPAL.GetStoryArcExchangePreview(payload)
    if not preview then return false,previewError end
    -- RPALARC1 uses record newlines; ':' is outside its numeric/hex grammar and
    -- keeps every addon-message chunk transport-safe without executing data.
    return SendExchangePayload("A",payload:gsub("\n",":"),target,preview)
end

function RPAL.SendDMCampaignExchange(campaignID,target)
    if not RPAL.DMKit or not RPAL.DMKit.ExportCampaign or not RPAL.DMKit.GetCampaignExchangePreview then return false,"DM Campaign Exchange is not available." end
    local payload,err=RPAL.DMKit.ExportCampaign(campaignID)
    if not payload then return false,err end
    local preview,previewError=RPAL.DMKit.GetCampaignExchangePreview(payload)
    if not preview then return false,previewError end
    return SendExchangePayload("D",payload,target,preview)
end

local function ExchangeKey(sender,transferID)
    return tostring(sender or ""):lower().."\031"..tostring(transferID or "")
end

local function ExchangeCapacity(sender)
    local total,fromSender=0,0;local wanted=tostring(sender or ""):lower()
    for _,entry in pairs(calendarExchangeIncoming) do
        total=total+1;if tostring(entry.sender or ""):lower()==wanted then fromSender=fromSender+1 end
    end
    for _,entry in pairs(calendarExchangeOffers) do
        total=total+1;if tostring(entry.sender or ""):lower()==wanted then fromSender=fromSender+1 end
    end
    return total<12 and fromSender<3
end

local function HandleCalendarExchangeMessage(message,sender)
    sender=CleanPlayerName(sender);message=tostring(message or "")
    if not sender then return end
    local kind,transferID,rest=message:match("^([^;]+);([^;]+);?(.*)$")
    if not kind or not transferID or #transferID>48 or transferID:find("[^%w%-]") then return end
    if kind=="O" then
        local assetType,count=rest:match("^([CAD]);(%d+)$")
        if not assetType then assetType="C";count=rest end -- 1.3.182 accepts the older Calendar-only opener.
        count=tonumber(count)
        if not count or count%1~=0 or count<1 or count>CALENDAR_EXCHANGE_MAX_CHUNKS then return end
        local settings=RPAL.db and RPAL.GetSettings and RPAL.GetSettings()
        if settings and settings.autoDeclineExchanges then
            -- Reject at the opener: do not allocate chunks, build a preview,
            -- enqueue a modal or emit a receiver-side notification that a
            -- spammer could itself abuse.
            ExchangeSend(sender,"X;"..transferID..";auto")
            return
        end
        if not ExchangeCapacity(sender) then ExchangeSend(sender,"E;"..transferID..";busy");return end
        local key=ExchangeKey(sender,transferID)
        calendarExchangeIncoming[key]={sender=sender,id=transferID,assetType=assetType,count=count,chunks={},received=0,createdAt=time()}
        if C_Timer and C_Timer.After then C_Timer.After(75,function()
            local entry=calendarExchangeIncoming[key]
            if entry and (time()-(entry.createdAt or 0))>=70 then calendarExchangeIncoming[key]=nil end
        end) end
        return
    end
    if kind=="D" then
        local index,chunk=rest:match("^(%d+);(.*)$")
        index=tonumber(index)
        local key=ExchangeKey(sender,transferID);local entry=calendarExchangeIncoming[key]
        if not entry or not index or index<1 or index>entry.count or #chunk>CALENDAR_EXCHANGE_CHUNK or chunk:find("[^%w;:]") then return end
        if not entry.chunks[index] then entry.chunks[index]=chunk;entry.received=entry.received+1 end
        if entry.received==entry.count then
            local payload=table.concat(entry.chunks)
            if entry.assetType=="A" then payload=payload:gsub(":","\n") end
            calendarExchangeIncoming[key]=nil
            local preview
            if entry.assetType=="A" and RPAL.GetStoryArcExchangePreview then preview=RPAL.GetStoryArcExchangePreview(payload)
            elseif entry.assetType=="D" and RPAL.DMKit and RPAL.DMKit.GetCampaignExchangePreview then preview=RPAL.DMKit.GetCampaignExchangePreview(payload)
            elseif entry.assetType=="C" then preview=RPAL.ImportCalendarExchangeEvent(payload) end
            if not preview then ExchangeSend(sender,"E;"..transferID..";invalid");return end
            calendarExchangeOffers[key]={sender=sender,id=transferID,assetType=entry.assetType,event=entry.assetType=="C" and preview or nil,preview=preview,payload=(entry.assetType=="A" or entry.assetType=="D") and payload or nil,createdAt=time()}
            if C_Timer and C_Timer.After then C_Timer.After(180,function()
                local offer=calendarExchangeOffers[key]
                if offer and (time()-(offer.createdAt or 0))>=170 then calendarExchangeOffers[key]=nil end
            end) end
            if entry.assetType=="A" then
                if RPAL.OnStoryArcExchangeOffer then RPAL.OnStoryArcExchangeOffer(sender,preview,payload,transferID) end
            elseif entry.assetType=="D" then
                if RPAL.OnDMCampaignExchangeOffer then RPAL.OnDMCampaignExchangeOffer(sender,preview,payload,transferID) end
            elseif RPAL.OnCalendarExchangeOffer then RPAL.OnCalendarExchangeOffer(sender,preview,transferID) end
        end
        return
    end
    if kind=="A" or kind=="X" or kind=="E" then
        local outgoing=calendarExchangeOutgoing[transferID]
        local targetKey=tostring(outgoing and outgoing.target or ""):lower()
        local senderKey=sender:lower()
        local samePlayer=targetKey==senderKey or (not targetKey:find("-",1,true) and targetKey==(senderKey:match("^([^%-]+)") or senderKey))
        if not outgoing or not samePlayer then return end
        calendarExchangeOutgoing[transferID]=nil
        if outgoing.assetType=="A" then
            if RPAL.OnStoryArcExchangeResult then RPAL.OnStoryArcExchangeResult(kind,sender,outgoing.preview,rest) end
        elseif outgoing.assetType=="D" then
            if RPAL.OnDMCampaignExchangeResult then RPAL.OnDMCampaignExchangeResult(kind,sender,outgoing.preview,rest) end
        elseif RPAL.OnCalendarExchangeResult then RPAL.OnCalendarExchangeResult(kind,sender,outgoing.preview,rest) end
    end
end
RPAL.HandleCalendarExchangeMessage=HandleCalendarExchangeMessage

local function HasExactCalendarEvent(candidate)
    for _,event in ipairs(CalendarDB().events) do
        if tonumber(event.year)==candidate.year and tonumber(event.month)==candidate.month and tonumber(event.day)==candidate.day
            and tonumber(event.hour)==candidate.hour and tonumber(event.minute)==candidate.minute
            and tostring(event.title or "")==candidate.title and tostring(event.note or "")==candidate.note then return true end
    end
    return false
end

function RPAL.AcceptCalendarExchangeOffer(sender,transferID)
    local key=ExchangeKey(sender,transferID);local offer=calendarExchangeOffers[key]
    if not offer then return false,"That Calendar Exchange offer expired." end
    if offer.assetType~="C" then return false,"That offer is not a Calendar event." end
    if HasExactCalendarEvent(offer.event) then return false,"This exact event is already in your Calendar." end
    local e=offer.event
    local ok,result=RPAL.AddCalendarEvent(e.year,e.month,e.day,e.hour,e.minute,e.title,e.note)
    if not ok then return false,result end
    calendarExchangeOffers[key]=nil;ExchangeSend(sender,"A;"..tostring(transferID)..";")
    return true,result
end

function RPAL.DeclineCalendarExchangeOffer(sender,transferID)
    local key=ExchangeKey(sender,transferID)
    if not calendarExchangeOffers[key] or calendarExchangeOffers[key].assetType~="C" then return false end
    calendarExchangeOffers[key]=nil;ExchangeSend(sender,"X;"..tostring(transferID)..";")
    return true
end

function RPAL.AcceptStoryArcExchangeOffer(sender,transferID)
    local key=ExchangeKey(sender,transferID);local offer=calendarExchangeOffers[key]
    if not offer then return false,"That Story Arc Exchange offer expired." end
    if offer.assetType~="A" or not RPAL.ImportStoryArc then return false,"That offer is not a Story Arc." end
    local ok,result=RPAL.ImportStoryArc(offer.payload)
    if not ok then return false,result end
    calendarExchangeOffers[key]=nil;ExchangeSend(sender,"A;"..tostring(transferID)..";")
    return true,result
end

function RPAL.DeclineStoryArcExchangeOffer(sender,transferID)
    local key=ExchangeKey(sender,transferID)
    if not calendarExchangeOffers[key] or calendarExchangeOffers[key].assetType~="A" then return false end
    calendarExchangeOffers[key]=nil;ExchangeSend(sender,"X;"..tostring(transferID)..";")
    return true
end

function RPAL.AcceptDMCampaignExchangeOffer(sender,transferID)
    local key=ExchangeKey(sender,transferID);local offer=calendarExchangeOffers[key]
    if not offer then return false,"That DM Campaign Exchange offer expired." end
    if offer.assetType~="D" or not RPAL.DMKit or not RPAL.DMKit.ImportCampaign then return false,"That offer is not a DM Campaign." end
    local ok,result=RPAL.DMKit.ImportCampaign(offer.payload)
    if not ok then return false,result end
    calendarExchangeOffers[key]=nil;ExchangeSend(sender,"A;"..tostring(transferID)..";")
    return true,result
end

function RPAL.DeclineDMCampaignExchangeOffer(sender,transferID)
    local key=ExchangeKey(sender,transferID)
    if not calendarExchangeOffers[key] or calendarExchangeOffers[key].assetType~="D" then return false end
    calendarExchangeOffers[key]=nil;ExchangeSend(sender,"X;"..tostring(transferID)..";")
    return true
end

RPAL.CALENDAR_EXCHANGE_PREFIX=CALENDAR_EXCHANGE_PREFIX

local eventFrame=CreateFrame("Frame")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterEvent("CHAT_MSG_ADDON")
eventFrame:SetScript("OnEvent",function(_,event,prefix,message,channel,sender)
    if event=="CHAT_MSG_ADDON" then
        if prefix==CALENDAR_EXCHANGE_PREFIX and channel=="WHISPER" then HandleCalendarExchangeMessage(message,sender) end
        return
    end
    if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then pcall(C_ChatInfo.RegisterAddonMessagePrefix,CALENDAR_EXCHANGE_PREFIX) end
    if C_Timer and C_Timer.After then
        C_Timer.After(4,function()
            RPAL.CheckCalendarReminders()
            if not reminderTicker and C_Timer.NewTicker then
                reminderTicker=C_Timer.NewTicker(45,function()RPAL.CheckCalendarReminders()end)
            end
        end)
    end
end)
