local ADDON_NAME, RPAL = ...

-- ============================================================
-- Story Arc graph data
-- ============================================================
-- StoryArc.lua deliberately sits on top of the original lightweight Arc API.
-- Existing SavedVariables are migrated lazily and in place, so a player never
-- loses the old three-step goals when upgrading to the board system.

local ARC_SCHEMA = 3
local MAX_ARCS = 40
local MAX_PINNED_ARCS = 3
local MAX_STEPS = 120
local MAX_LINKS = 300
local MAX_OUTGOING_LINKS = 6
-- The board is intentionally much larger than the visible viewport.  A large
-- graph should grow into a real workspace instead of forcing 30+ events into
-- the original 1800x1100 drafting area.
local BOARD_W = 4600
local BOARD_H = 2800
local MAX_LOCATION_POOL = 400

local SEEDED_LOCATIONS = {
    "Stormwind City","Ironforge","Darnassus","The Exodar","Boralus","Valdrakken",
    "Orgrimmar","Silvermoon City","Thunder Bluff","Undercity","Shattrath City",
    "Dalaran","Dornogal",
    "The Shady Lady","The Slaughtered Lamb","Blue Recluse","Pig and Whistle",
    "Hamlet","Golden Keg",
}

local function Trim(value)
    return tostring(value or ""):gsub("^%s+",""):gsub("%s+$","")
end

local function Clamp(value,low,high)
    value=tonumber(value) or low
    if value<low then return low end
    if value>high then return high end
    return value
end

local function UTF8Truncate(value,maximum)
    value=tostring(value or "")
    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 CleanLocation(value)
    value=tostring(value or "")
    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("|","")
    value=value:gsub("[%z\1-\31]"," ")
    value=value:gsub("%s+"," "):gsub("^%s+",""):gsub("%s+$","")
    value=UTF8Truncate(value,80)
    return value
end

local function LocationPool()
    if type(RPAL_DB)~="table" then return nil end
    RPAL_DB.storyLocationPool=type(RPAL_DB.storyLocationPool)=="table" and RPAL_DB.storyLocationPool or {}
    local pool=RPAL_DB.storyLocationPool
    if not pool._seeded then
        pool._seeded=true
        for _,name in ipairs(SEEDED_LOCATIONS) do
            local key=name:lower()
            pool[key]=pool[key] or {name=name,source="seed",seed=true,uses=0,firstSeen=time(),lastSeen=time()}
        end
    end
    return pool
end

function RPAL.LearnStoryLocation(value,source,peer)
    local name=CleanLocation(value)
    local pool=LocationPool()
    if not pool or #name<2 then return false end
    local key=name:lower()
    local entry=pool[key]
    if not entry then
        local count=0
        for candidate,item in pairs(pool) do if candidate~="_seeded" and type(item)=="table" then count=count+1 end end
        if count>=MAX_LOCATION_POOL then
            local removeKey,removeTime
            for candidate,item in pairs(pool) do
                if candidate~="_seeded" and type(item)=="table" and not item.seed then
                    local seen=tonumber(item.lastSeen) or 0
                    if not removeTime or seen<removeTime then removeKey,removeTime=candidate,seen end
                end
            end
            if removeKey then pool[removeKey]=nil else return false end
        end
        entry={name=name,firstSeen=time(),lastSeen=time(),uses=0}
        pool[key]=entry
    end
    entry.name=(#name>#tostring(entry.name or "")) and name or (entry.name or name)
    entry.lastSeen=time()
    entry.uses=math.min(100000,(tonumber(entry.uses) or 0)+1)
    source=tostring(source or "custom")
    if source~="seed" and not entry.seed then entry.source=source;entry.custom=true end
    if peer and tostring(peer)~="" then entry.peer=tostring(peer);entry.peerLearned=true end
    return true,entry
end

function RPAL.GetStoryLocationSuggestions(query,limit)
    local pool=LocationPool() or {}
    query=CleanLocation(query):lower()
    limit=math.max(1,math.min(10,math.floor(tonumber(limit) or 6)))
    local ranked={}
    for key,entry in pairs(pool) do
        if key~="_seeded" and type(entry)=="table" then
            local name=CleanLocation(entry.name)
            local lower=name:lower()
            local at=(query=="") and 1 or lower:find(query,1,true)
            if at then
                local rank=(at==1 and 0 or (lower:find(" "..query,1,true) and 1 or 2))
                ranked[#ranked+1]={name=name,rank=rank,uses=tonumber(entry.uses) or 0,lastSeen=tonumber(entry.lastSeen) or 0}
            end
        end
    end
    table.sort(ranked,function(a,b)
        if a.rank~=b.rank then return a.rank<b.rank end
        if a.uses~=b.uses then return a.uses>b.uses end
        if a.lastSeen~=b.lastSeen then return a.lastSeen>b.lastSeen end
        return a.name:lower()<b.name:lower()
    end)
    local out={}
    for index=1,math.min(limit,#ranked) do out[index]=ranked[index].name end
    return out
end

function RPAL.GetShareableStoryLocations(limit)
    local pool=LocationPool() or {}
    local ranked={}
    for key,entry in pairs(pool) do
        if key~="_seeded" and type(entry)=="table" and not entry.seed and (entry.custom or entry.peerLearned) then
            ranked[#ranked+1]=entry
        end
    end
    table.sort(ranked,function(a,b)
        local aa,bb=tonumber(a.lastSeen) or 0,tonumber(b.lastSeen) or 0
        if aa==bb then return (tonumber(a.uses) or 0)>(tonumber(b.uses) or 0) end
        return aa>bb
    end)
    local out={}
    for index=1,math.min(math.max(1,math.min(12,tonumber(limit) or 8)),#ranked) do out[index]=CleanLocation(ranked[index].name) end
    return out
end

local function NewID(prefix)
    if RPAL.NewID then return RPAL.NewID(prefix) end
    RPAL._storyArcFallbackID=(tonumber(RPAL._storyArcFallbackID) or 0)+1
    return tostring(prefix or "story").."-"..tostring(time()).."-"..tostring(RPAL._storyArcFallbackID)
end

local function ArcData()
    local data=RPAL.GetRoleplayData and RPAL.GetRoleplayData()
    if not data then return nil end
    data.arcs=data.arcs or {}
    return data
end

local function FindArcRaw(id)
    id=tostring(id or "")
    local data=ArcData()
    if not data then return nil end
    for _,arc in ipairs(data.arcs or {}) do
        if tostring(arc.id or "")==id then return arc end
    end
    return nil
end

local function DefaultStepPosition(index)
    index=math.max(1,tonumber(index) or 1)
    local col=(index-1)%10
    local row=math.floor((index-1)/10)
    return 100+(col*270),110+(row*160)
end

local function NewStep(arc,data,index)
    data=data or {}
    local x,y=DefaultStepPosition(index or (#(arc.stepOrder or {})+1))
    return {
        id=NewID("arcstep"),
        title=Trim(data.title)~="" and Trim(data.title) or "New Event",
        note=tostring(data.note or ""),
        location=CleanLocation(data.location),
        bondKeys=type(data.bondKeys)=="table" and data.bondKeys or {},
        x=Clamp(data.x or x,15,BOARD_W-205),
        y=Clamp(data.y or y,15,BOARD_H-140),
        completed=data.completed and true or false,
        createdAt=tonumber(data.createdAt) or time(),
        updatedAt=tonumber(data.updatedAt) or time(),
    }
end

local function NormalizeBondKeys(value)
    local out,seen={},{}
    if type(value)=="table" then
        for key,item in pairs(value) do
            local candidate
            if type(key)=="number" then candidate=item elseif item then candidate=key end
            candidate=Trim(candidate):lower()
            if candidate~="" and not seen[candidate] then
                seen[candidate]=true
                out[#out+1]=candidate
            end
        end
    end
    table.sort(out)
    return out
end

local function NormalizeArc(arc)
    if type(arc)~="table" then return nil end
    arc.id=tostring(arc.id or NewID("arc"))
    arc.title=Trim(arc.title)~="" and Trim(arc.title) or "Untitled Story Arc"
    arc.note=tostring(arc.note or "")
    arc.bondKeys=NormalizeBondKeys and NormalizeBondKeys(arc.bondKeys) or (arc.bondKeys or {})
    local musicID=tonumber(arc.musicID)
    arc.musicID=(musicID and musicID>0 and musicID<4294967296) and math.floor(musicID) or nil
    arc.musicName=Trim(arc.musicName)
    arc.createdAt=tonumber(arc.createdAt) or time()
    arc.updatedAt=tonumber(arc.updatedAt) or arc.createdAt

    local hadGraph=(tonumber(arc.schema) or 0)>=ARC_SCHEMA or (type(arc.steps)=="table" and next(arc.steps)~=nil)
    arc.steps=type(arc.steps)=="table" and arc.steps or {}
    arc.stepOrder=type(arc.stepOrder)=="table" and arc.stepOrder or {}
    arc.links=type(arc.links)=="table" and arc.links or {}
    arc.view=type(arc.view)=="table" and arc.view or {x=0,y=0,zoom=1}
    arc.view.x=tonumber(arc.view.x) or 0
    arc.view.y=tonumber(arc.view.y) or 0
    arc.view.zoom=Clamp(arc.view.zoom or 1,0.28,1.35)

    -- Migrate the old "progress / target" Arc into an editable linear graph.
    if not hadGraph then
        local target=math.max(1,math.min(12,math.floor(tonumber(arc.target) or 3)))
        local progress=math.max(0,math.floor(tonumber(arc.progress) or 0))
        local previous
        for index=1,target do
            local step=NewStep(arc,{
                title=(index==1 and "Beginning") or ("Chapter "..tostring(index)),
                completed=index<=progress,
                x=80+((index-1)*250),y=130,
            },index)
            arc.steps[step.id]=step
            arc.stepOrder[#arc.stepOrder+1]=step.id
            if not arc.rootStepID then arc.rootStepID=step.id end
            if previous then
                local link={id=NewID("arclink"),from=previous,to=step.id,label="",order=index-1,createdAt=time()}
                arc.links[#arc.links+1]=link
            end
            previous=step.id
        end
    end

    -- Repair sparse or legacy step arrays/maps.
    local repairedSteps={}
    local seenStep={}
    local repairedOrder={}
    for _,stepID in ipairs(arc.stepOrder or {}) do
        stepID=tostring(stepID or "")
        local step=arc.steps[stepID]
        if type(step)=="table" and not seenStep[stepID] then
            step.id=stepID
            repairedSteps[stepID]=step
            repairedOrder[#repairedOrder+1]=stepID
            seenStep[stepID]=true
        end
    end
    for key,step in pairs(arc.steps or {}) do
        if type(step)=="table" then
            local stepID=tostring(step.id or key or NewID("arcstep"))
            if not seenStep[stepID] then
                step.id=stepID
                repairedSteps[stepID]=step
                repairedOrder[#repairedOrder+1]=stepID
                seenStep[stepID]=true
            end
        end
    end
    arc.steps=repairedSteps
    arc.stepOrder=repairedOrder

    for index,stepID in ipairs(arc.stepOrder) do
        local step=arc.steps[stepID]
        local dx,dy=DefaultStepPosition(index)
        step.title=Trim(step.title)~="" and Trim(step.title) or ("Event "..tostring(index))
        step.note=tostring(step.note or "")
        step.location=CleanLocation(step.location)
        step.bondKeys=NormalizeBondKeys(step.bondKeys)
        step.x=Clamp(step.x or dx,15,BOARD_W-205)
        step.y=Clamp(step.y or dy,15,BOARD_H-140)
        step.completed=step.completed and true or false
        step.createdAt=tonumber(step.createdAt) or arc.createdAt
        step.updatedAt=tonumber(step.updatedAt) or step.createdAt
    end

    local repairedLinks={}
    local seenLink={}
    for _,link in ipairs(arc.links or {}) do
        if type(link)=="table" then
            link.from=tostring(link.from or "")
            link.to=tostring(link.to or "")
            if arc.steps[link.from] and arc.steps[link.to] and link.from~=link.to then
                local pair=link.from.."\031"..link.to
                if not seenLink[pair] then
                    link.id=tostring(link.id or NewID("arclink"))
                    link.label=Trim(link.label)
                    link.order=tonumber(link.order) or (#repairedLinks+1)
                    link.createdAt=tonumber(link.createdAt) or arc.createdAt
                    repairedLinks[#repairedLinks+1]=link
                    seenLink[pair]=true
                end
            end
        end
    end
    arc.links=repairedLinks
    if not arc.rootStepID or not arc.steps[arc.rootStepID] then arc.rootStepID=arc.stepOrder[1] end
    arc.schema=ARC_SCHEMA
    return arc
end
RPAL.NormalizeStoryArc=NormalizeArc

local function SyncProgress(arc)
    arc=NormalizeArc(arc)
    if not arc then return end
    local done=0
    for _,stepID in ipairs(arc.stepOrder or {}) do
        if arc.steps[stepID] and arc.steps[stepID].completed then done=done+1 end
    end
    arc.progress=done
    arc.target=#(arc.stepOrder or {})
    if arc.target>0 and done>=arc.target and not arc.forceOpen then
        arc.completed=true
        arc.completedAt=tonumber(arc.completedAt) or time()
    elseif arc.completed and done<arc.target then
        -- Explicitly completed files remain complete until reopened by the
        -- player. Editing a completed graph should not silently change status.
    else
        arc.completed=false
        arc.completedAt=nil
    end
    arc.updatedAt=time()
end

local function FindStep(arc,stepID)
    arc=NormalizeArc(arc)
    return arc and arc.steps[tostring(stepID or "")] or nil
end

function RPAL.CreateStoryArc(title,note)
    local data=ArcData()
    title=Trim(title)
    if not data or title=="" then return false,"Give the Story Arc a name." end
    if #(data.arcs or {})>=MAX_ARCS then return false,"You can keep up to "..tostring(MAX_ARCS).." Story Arc files." end
    local arc={
        id=NewID("arc"),title=title,note=tostring(note or ""),schema=ARC_SCHEMA,
        createdAt=time(),updatedAt=time(),completed=false,
        steps={},stepOrder={},links={},bondKeys={},view={x=0,y=0,zoom=1},
    }
    local beginning=NewStep(arc,{title="Beginning",x=100,y=160},1)
    arc.steps[beginning.id]=beginning
    arc.stepOrder[1]=beginning.id
    arc.rootStepID=beginning.id
    SyncProgress(arc)
    table.insert(data.arcs,1,arc)
    return true,arc
end

function RPAL.GetStoryArcs()
    local data=ArcData()
    if not data then return {} end
    for _,arc in ipairs(data.arcs or {}) do NormalizeArc(arc) end
    return data.arcs
end

function RPAL.GetStoryArc(id)
    return NormalizeArc(FindArcRaw(id))
end

function RPAL.UpdateStoryArc(id,fields)
    local arc=RPAL.GetStoryArc(id)
    if not arc then return false,"Story Arc could not be found." end
    fields=fields or {}
    if fields.title~=nil then
        local title=Trim(fields.title)
        if title=="" then return false,"Give the Story Arc a name." end
        arc.title=title
    end
    if fields.note~=nil then arc.note=tostring(fields.note or "") end
    if fields.bondKeys~=nil then arc.bondKeys=NormalizeBondKeys(fields.bondKeys) end
    if fields.musicID~=nil then
        local musicID=tonumber(fields.musicID)
        arc.musicID=(musicID and musicID>0) and math.floor(musicID) or nil
    end
    if fields.musicName~=nil then arc.musicName=Trim(fields.musicName) end
    if fields.completed~=nil then
        arc.completed=fields.completed and true or false
        arc.forceOpen=fields.completed and nil or true
        arc.completedAt=arc.completed and time() or nil
    end
    arc.updatedAt=time()
    return true,arc
end

function RPAL.AddStoryArcStep(arcID,fields)
    local arc=RPAL.GetStoryArc(arcID)
    if not arc then return false,"Story Arc could not be found." end
    if #arc.stepOrder>=MAX_STEPS then return false,"This Story Arc already has "..tostring(MAX_STEPS).." events." end
    local step=NewStep(arc,fields,#arc.stepOrder+1)
    arc.steps[step.id]=step
    arc.stepOrder[#arc.stepOrder+1]=step.id
    if not arc.rootStepID then arc.rootStepID=step.id end
    SyncProgress(arc)
    return true,step
end

function RPAL.UpdateStoryArcStep(arcID,stepID,fields)
    local arc=RPAL.GetStoryArc(arcID)
    local step=arc and FindStep(arc,stepID)
    if not step then return false,"Story event could not be found." end
    fields=fields or {}
    if fields.title~=nil then
        local title=Trim(fields.title)
        if title=="" then return false,"Give the event a title." end
        step.title=title
    end
    if fields.note~=nil then step.note=tostring(fields.note or "") end
    if fields.location~=nil then
        step.location=CleanLocation(fields.location)
        if step.location~="" then RPAL.LearnStoryLocation(step.location,"arc") end
    end
    if fields.bondKeys~=nil then step.bondKeys=NormalizeBondKeys(fields.bondKeys) end
    if fields.x~=nil then step.x=Clamp(fields.x,15,BOARD_W-205) end
    if fields.y~=nil then step.y=Clamp(fields.y,15,BOARD_H-140) end
    if fields.completed~=nil then step.completed=fields.completed and true or false end
    step.updatedAt=time()
    SyncProgress(arc)
    return true,step
end

function RPAL.DeleteStoryArcStep(arcID,stepID)
    local arc=RPAL.GetStoryArc(arcID)
    stepID=tostring(stepID or "")
    if not arc or not arc.steps[stepID] then return false,"Story event could not be found." end
    arc.steps[stepID]=nil
    for index=#arc.stepOrder,1,-1 do
        if arc.stepOrder[index]==stepID then table.remove(arc.stepOrder,index) end
    end
    for index=#arc.links,1,-1 do
        local link=arc.links[index]
        if link.from==stepID or link.to==stepID then table.remove(arc.links,index) end
    end
    if arc.rootStepID==stepID then arc.rootStepID=arc.stepOrder[1] end
    SyncProgress(arc)
    return true,arc
end

function RPAL.SetStoryArcRoot(arcID,stepID)
    local arc=RPAL.GetStoryArc(arcID)
    stepID=tostring(stepID or "")
    if not arc or not arc.steps[stepID] then return false,"Story event could not be found." end
    arc.rootStepID=stepID
    arc.updatedAt=time()
    return true,arc
end

local function Reaches(arc,startID,wantedID,seen)
    if startID==wantedID then return true end
    seen=seen or {}
    if seen[startID] then return false end
    seen[startID]=true
    for _,link in ipairs(arc.links or {}) do
        if link.from==startID and Reaches(arc,link.to,wantedID,seen) then return true end
    end
    return false
end

function RPAL.ConnectStoryArcSteps(arcID,fromID,toID,label)
    local arc=RPAL.GetStoryArc(arcID)
    fromID=tostring(fromID or "");toID=tostring(toID or "")
    if not arc or not arc.steps[fromID] or not arc.steps[toID] then return false,"Choose two valid story events." end
    if fromID==toID then return false,"An event cannot connect to itself." end
    for _,link in ipairs(arc.links) do
        if link.from==fromID and link.to==toID then return false,"These events are already connected." end
    end
    local outgoingCount=0
    for _,link in ipairs(arc.links) do if link.from==fromID then outgoingCount=outgoingCount+1 end end
    if outgoingCount>=MAX_OUTGOING_LINKS then
        return false,"A single event can branch into up to "..tostring(MAX_OUTGOING_LINKS).." paths so every choice remains visible in Watch."
    end
    if #arc.links>=MAX_LINKS then return false,"This Story Arc has reached its connection limit." end
    if Reaches(arc,toID,fromID,{}) then return false,"That connection would create a loop. Story paths must move forward." end
    local order=1
    for _,link in ipairs(arc.links) do
        if link.from==fromID then order=math.max(order,(tonumber(link.order) or 0)+1) end
    end
    local link={id=NewID("arclink"),from=fromID,to=toID,label=Trim(label),order=order,createdAt=time()}
    arc.links[#arc.links+1]=link
    arc.updatedAt=time()
    return true,link
end

function RPAL.DeleteStoryArcLink(arcID,linkID)
    local arc=RPAL.GetStoryArc(arcID)
    linkID=tostring(linkID or "")
    if not arc then return false end
    for index,link in ipairs(arc.links or {}) do
        if tostring(link.id or "")==linkID then
            table.remove(arc.links,index)
            arc.updatedAt=time()
            return true,link
        end
    end
    return false
end

function RPAL.GetStoryArcOutgoing(arcOrID,stepID)
    local arc=type(arcOrID)=="table" and NormalizeArc(arcOrID) or RPAL.GetStoryArc(arcOrID)
    local out={}
    if not arc then return out end
    stepID=tostring(stepID or "")
    for _,link in ipairs(arc.links or {}) do
        if link.from==stepID and arc.steps[link.to] then out[#out+1]=link end
    end
    table.sort(out,function(a,b)
        local ao,bo=tonumber(a.order) or 0,tonumber(b.order) or 0
        if ao==bo then return (tonumber(a.createdAt) or 0)<(tonumber(b.createdAt) or 0) end
        return ao<bo
    end)
    return out
end

function RPAL.GetStoryArcIncoming(arcOrID,stepID)
    local arc=type(arcOrID)=="table" and NormalizeArc(arcOrID) or RPAL.GetStoryArc(arcOrID)
    local out={}
    if not arc then return out end
    stepID=tostring(stepID or "")
    for _,link in ipairs(arc.links or {}) do
        if link.to==stepID and arc.steps[link.from] then out[#out+1]=link end
    end
    return out
end

function RPAL.GetStoryArcStats(arcOrID)
    local arc=type(arcOrID)=="table" and NormalizeArc(arcOrID) or RPAL.GetStoryArc(arcOrID)
    if not arc then return {steps=0,links=0,branches=0,endings=0,completed=0} end
    local stats={steps=#arc.stepOrder,links=#arc.links,branches=0,endings=0,completed=0}
    for _,stepID in ipairs(arc.stepOrder) do
        local outgoing=RPAL.GetStoryArcOutgoing(arc,stepID)
        if #outgoing>1 then stats.branches=stats.branches+1 end
        if #outgoing==0 then stats.endings=stats.endings+1 end
        if arc.steps[stepID].completed then stats.completed=stats.completed+1 end
    end
    return stats
end

function RPAL.GetStoryArcBondNames(step)
    if type(step)=="table" and type(step.bondNames)=="table" and #step.bondNames>0 then
        local copied={}
        for _,name in ipairs(step.bondNames) do
            name=Trim(name)
            if name~="" then copied[#copied+1]=name end
        end
        table.sort(copied,function(a,b)return a:lower()<b:lower() end)
        return copied
    end
    local byKey={}
    for _,bond in ipairs(RPAL.GetBonds and RPAL.GetBonds() or {}) do
        byKey[tostring(bond.key or ""):lower()]=bond.name or bond.trpName or bond.key
    end
    local names={}
    for _,key in ipairs((step and step.bondKeys) or {}) do
        names[#names+1]=tostring(byKey[tostring(key):lower()] or key)
    end
    table.sort(names,function(a,b)return a:lower()<b:lower() end)
    return names
end

function RPAL.GetStoryArcFileBondNames(arcOrID)
    local arc=type(arcOrID)=="table" and NormalizeArc(arcOrID) or RPAL.GetStoryArc(arcOrID)
    return RPAL.GetStoryArcBondNames(arc or {})
end

function RPAL.SetStoryArcPinned(id,wanted)
    local arc=RPAL.GetStoryArc(id)
    if not arc then return false,"Story Arc could not be found." end
    wanted=wanted and true or false
    if wanted and not arc.pinned then
        local pinned=0
        for _,item in ipairs(RPAL.GetStoryArcs()) do if item.pinned then pinned=pinned+1 end end
        if pinned>=MAX_PINNED_ARCS then return false,"You can pin up to "..tostring(MAX_PINNED_ARCS).." Story Arcs to your profile." end
    end
    arc.pinned=wanted
    arc.pinnedAt=wanted and (tonumber(arc.pinnedAt) or time()) or nil
    arc.updatedAt=time()
    return true,arc
end

function RPAL.GetPinnedStoryArcs()
    local pinned={}
    for _,arc in ipairs(RPAL.GetStoryArcs()) do
        if arc.pinned then pinned[#pinned+1]=arc end
    end
    table.sort(pinned,function(a,b)
        local aa,bb=tonumber(a.pinnedAt) or 0,tonumber(b.pinnedAt) or 0
        if aa==bb then return tostring(a.title or ""):lower()<tostring(b.title or ""):lower() end
        return aa>bb
    end)
    return pinned
end

function RPAL.SetStoryArcView(arcID,x,y,zoom)
    local arc=RPAL.GetStoryArc(arcID)
    if not arc then return false end
    arc.view=arc.view or {}
    if x~=nil then arc.view.x=tonumber(x) or 0 end
    if y~=nil then arc.view.y=tonumber(y) or 0 end
    if zoom~=nil then arc.view.zoom=Clamp(zoom,0.28,1.35) end
    arc.updatedAt=time()
    return true,arc.view
end

-- Compatibility: console commands and older UI buttons advance the first
-- incomplete event instead of mutating an abstract counter.
function RPAL.AdvanceStoryArc(id,amount)
    local arc=RPAL.GetStoryArc(id)
    if not arc then return false end
    amount=math.max(1,math.floor(tonumber(amount) or 1))
    for _,stepID in ipairs(arc.stepOrder) do
        local step=arc.steps[stepID]
        if step and not step.completed and amount>0 then
            step.completed=true
            step.updatedAt=time()
            amount=amount-1
        end
    end
    SyncProgress(arc)
    return true,arc
end

function RPAL.ToggleStoryArcComplete(id)
    local arc=RPAL.GetStoryArc(id)
    if not arc then return false end
    local wanted=not arc.completed
    arc.completed=wanted
    arc.forceOpen=wanted and nil or true
    arc.completedAt=wanted and time() or nil
    if wanted then
        for _,stepID in ipairs(arc.stepOrder) do
            if arc.steps[stepID] then arc.steps[stepID].completed=true end
        end
    end
    SyncProgress(arc)
    if not wanted then arc.completed=false;arc.completedAt=nil end
    return true,arc
end

function RPAL.DeleteStoryArc(id)
    local data=ArcData()
    if not data then return false end
    id=tostring(id or "")
    for index,arc in ipairs(data.arcs or {}) do
        if tostring(arc.id or "")==id then
            table.remove(data.arcs,index)
            return true,arc
        end
    end
    return false
end

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

local function ByteHexDecode(value,maximum)
    value=tostring(value or "")
    if #value%2~=0 or value:find("[^%x]") then return nil end
    if 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

local function SplitFields(line)
    local fields={}
    local start=1
    while true do
        local at=line:find(";",start,true)
        if not at then fields[#fields+1]=line:sub(start);break end
        fields[#fields+1]=line:sub(start,at-1);start=at+1
    end
    return fields
end

-- Read-only, non-mutating summary used by Online Exchange.  The full importer
-- validates graph references and cycles again after the player accepts; this
-- pass exists so an incoming file can be inspected without creating Bonds,
-- learning locations, or touching the Story Arc database.
local function ExchangePreviewText(encoded,maximum)
    local value=ByteHexDecode(encoded,maximum)
    if value==nil or value:find("|",1,true) or value:find("[%z\1-\8\11\12\14-\31]") then return nil end
    return value
end

function RPAL.GetStoryArcExchangePreview(payload)
    payload=tostring(payload or ""):gsub("\r\n","\n"):gsub("\r","\n")
    if #payload>1048576 then return nil,"Story Arc exchange is too large." end
    local preview={events={},bondNames={},eventCount=0,linkCount=0,bondCount=0}
    local eventIndices,bondKeys={},{}
    local headerSeen,lineCount=false,0
    local function RememberBond(key,name)
        key=Trim(key):lower();name=Trim(name)
        local identity=key~="" and key or name:lower()
        if identity~="" and not bondKeys[identity] then
            bondKeys[identity]=true
            preview.bondNames[#preview.bondNames+1]=name~="" and name or key
        end
    end
    for line in (payload.."\n"):gmatch("(.-)\n") do
        if line~="" then
            lineCount=lineCount+1
            if lineCount>800 then return nil,"Story Arc exchange has too many records." end
            local f=SplitFields(line)
            if f[1]=="RPALARC1" then
                if headerSeen then return nil,"Story Arc exchange has more than one header." end
                headerSeen=true
                preview.title=ExchangePreviewText(f[2],120);preview.note=ExchangePreviewText(f[3],16000)
                if not preview.title or Trim(preview.title)=="" or preview.note==nil then return nil,"Story Arc exchange header is malformed." end
                preview.createdAt=tonumber(f[4]) or 0;preview.completed=f[5]=="1"
                preview.musicID=tonumber(f[7]);preview.musicName=ExchangePreviewText(f[8],160)
                if preview.musicName==nil then return nil,"Story Arc exchange music record is malformed." end
            elseif f[1]=="AB" then
                local key,name=ExchangePreviewText(f[2],120),ExchangePreviewText(f[3],160)
                if not key or name==nil then return nil,"Story Arc exchange Bond record is malformed." end
                RememberBond(key,name)
            elseif f[1]=="E" then
                local index=tonumber(f[2]);local title=ExchangePreviewText(f[3],160);local note=ExchangePreviewText(f[4],8000);local location=ExchangePreviewText(f[5],120)
                if not index or index%1~=0 or index<1 or index>MAX_STEPS or eventIndices[index] or not title or Trim(title)=="" or note==nil or location==nil then
                    return nil,"Story Arc exchange event record is malformed."
                end
                eventIndices[index]=true
                preview.events[#preview.events+1]={index=index,title=title,note=note,location=location,completed=f[8]=="1"}
            elseif f[1]=="EB" then
                local index=tonumber(f[2]);local key,name=ExchangePreviewText(f[3],120),ExchangePreviewText(f[4],160)
                if not index or index%1~=0 or not key or name==nil then return nil,"Story Arc exchange event Bond record is malformed." end
                RememberBond(key,name)
            elseif f[1]=="L" then
                local fromIndex,toIndex=tonumber(f[2]),tonumber(f[3]);local label=ExchangePreviewText(f[4],160)
                if not fromIndex or not toIndex or fromIndex%1~=0 or toIndex%1~=0 or not label then return nil,"Story Arc exchange connection record is malformed." end
                preview.linkCount=preview.linkCount+1
                if preview.linkCount>MAX_LINKS then return nil,"Story Arc exchange has too many connections." end
            else
                return nil,"Story Arc exchange contains an unknown record."
            end
        end
    end
    if not headerSeen then return nil,"This is not an ODYN Story Arc export." end
    if #preview.events==0 then return nil,"Story Arc exchange contains no events." end
    table.sort(preview.events,function(a,b)return a.index<b.index end)
    table.sort(preview.bondNames,function(a,b)return tostring(a):lower()<tostring(b):lower() end)
    preview.eventCount=#preview.events;preview.bondCount=#preview.bondNames
    return preview
end

function RPAL.ExportStoryArc(arcOrID)
    local arc=type(arcOrID)=="table" and NormalizeArc(arcOrID) or RPAL.GetStoryArc(arcOrID)
    if not arc then return nil,"Story Arc could not be found." end
    local indexByID={}
    local bondNameByKey={}
    for _,bond in ipairs(RPAL.GetBonds and RPAL.GetBonds() or {}) do
        bondNameByKey[tostring(bond.key or ""):lower()]=Trim(bond.name or bond.trpName or bond.key)
    end
    for index,stepID in ipairs(arc.stepOrder or {}) do indexByID[stepID]=index end
    local rootIndex=indexByID[arc.rootStepID] or 1
    local lines={table.concat({
        "RPALARC1",ByteHexEncode(arc.title),ByteHexEncode(arc.note),tostring(arc.createdAt or 0),
        arc.completed and "1" or "0",tostring(rootIndex),tostring(arc.musicID or ""),ByteHexEncode(arc.musicName or ""),
    },";")}
    for _,key in ipairs(arc.bondKeys or {}) do
        lines[#lines+1]=table.concat({"AB",ByteHexEncode(key),ByteHexEncode(bondNameByKey[tostring(key):lower()] or key)},";")
    end
    for index,stepID in ipairs(arc.stepOrder or {}) do
        local step=arc.steps[stepID]
        lines[#lines+1]=table.concat({
            "E",tostring(index),ByteHexEncode(step.title),ByteHexEncode(step.note),ByteHexEncode(step.location),
            tostring(math.floor(tonumber(step.x) or 0)),tostring(math.floor(tonumber(step.y) or 0)),step.completed and "1" or "0",
        },";")
        for _,key in ipairs(step.bondKeys or {}) do
            lines[#lines+1]=table.concat({"EB",tostring(index),ByteHexEncode(key),ByteHexEncode(bondNameByKey[tostring(key):lower()] or key)},";")
        end
    end
    for _,link in ipairs(arc.links or {}) do
        local fromIndex,toIndex=indexByID[link.from],indexByID[link.to]
        if fromIndex and toIndex then
            lines[#lines+1]=table.concat({"L",tostring(fromIndex),tostring(toIndex),ByteHexEncode(link.label),tostring(link.order or 1)},";")
        end
    end
    return table.concat(lines,"\n")
end

function RPAL.ImportStoryArc(payload)
    payload=tostring(payload or ""):gsub("\r\n","\n"):gsub("\r","\n")
    if #payload>1048576 then return false,"Story Arc import is too large." end
    local data=ArcData()
    if not data then return false,"Character data is not ready." end
    if #(data.arcs or {})>=MAX_ARCS then return false,"You can keep up to "..tostring(MAX_ARCS).." Story Arc files." end

    local arc={id=NewID("arc"),schema=ARC_SCHEMA,steps={},stepOrder={},links={},bondKeys={},view={x=0,y=0,zoom=1}}
    local indexedSteps={}
    local pendingArcBonds,pendingBonds,pendingLinks={},{},{}
    local headerSeen,lineCount=false,0
    for line in (payload.."\n"):gmatch("(.-)\n") do
        if line~="" then
            lineCount=lineCount+1
            if lineCount>800 then return false,"Story Arc import has too many records." end
            local f=SplitFields(line)
            if f[1]=="RPALARC1" then
                if headerSeen then return false,"Story Arc import has more than one header." end
                headerSeen=true
                arc.title=ByteHexDecode(f[2],120);arc.note=ByteHexDecode(f[3],16000)
                if not arc.title or Trim(arc.title)=="" or arc.note==nil then return false,"Story Arc header is malformed." end
                arc.createdAt=tonumber(f[4]) or time();arc.updatedAt=time();arc.completed=f[5]=="1"
                arc._rootIndex=math.floor(tonumber(f[6]) or 1)
                local musicID=tonumber(f[7]);arc.musicID=(musicID and musicID>0 and musicID<4294967296) and math.floor(musicID) or nil
                arc.musicName=ByteHexDecode(f[8],160) or ""
            elseif f[1]=="AB" then
                local key=ByteHexDecode(f[2],120);local name=ByteHexDecode(f[3],160)
                if not key or name==nil then return false,"Story Arc Bond record is malformed." end
                pendingArcBonds[#pendingArcBonds+1]={key=key,name=name}
            elseif f[1]=="E" then
                local index=tonumber(f[2]);local title=ByteHexDecode(f[3],160);local note=ByteHexDecode(f[4],8000);local location=ByteHexDecode(f[5],120)
                if not index or index%1~=0 or index<1 or index>MAX_STEPS or indexedSteps[index] or not title or Trim(title)=="" or note==nil or location==nil then
                    return false,"Story Arc event record is malformed."
                end
                indexedSteps[index]={
                    id=NewID("arcstep"),title=title,note=note,location=CleanLocation(location),bondKeys={},
                    x=Clamp(tonumber(f[6]) or 15,15,BOARD_W-205),y=Clamp(tonumber(f[7]) or 15,15,BOARD_H-140),
                    completed=f[8]=="1",createdAt=arc.createdAt or time(),updatedAt=time(),
                }
            elseif f[1]=="EB" then
                local index=tonumber(f[2]);local key=ByteHexDecode(f[3],120);local name=ByteHexDecode(f[4],160)
                if not index or index%1~=0 or not key or name==nil then return false,"Story Arc event Bond record is malformed." end
                pendingBonds[#pendingBonds+1]={index=index,key=key,name=name}
            elseif f[1]=="L" then
                local fromIndex,toIndex=tonumber(f[2]),tonumber(f[3]);local label=ByteHexDecode(f[4],160)
                if not fromIndex or not toIndex or fromIndex%1~=0 or toIndex%1~=0 or not label then return false,"Story Arc connection record is malformed." end
                pendingLinks[#pendingLinks+1]={from=fromIndex,to=toIndex,label=label,order=tonumber(f[5]) or #pendingLinks+1}
                if #pendingLinks>MAX_LINKS then return false,"Story Arc import has too many connections." end
            else
                return false,"Story Arc import contains an unknown record."
            end
        end
    end
    if not headerSeen then return false,"This is not an ODYN Story Arc export." end
    local stepCount=0
    for index=1,MAX_STEPS do
        local step=indexedSteps[index]
        if step then stepCount=stepCount+1;arc.steps[step.id]=step;arc.stepOrder[#arc.stepOrder+1]=step.id end
    end
    if stepCount==0 then return false,"Story Arc import contains no events." end
    for _,item in ipairs(pendingBonds) do if not indexedSteps[item.index] then return false,"Story Arc Bond refers to a missing event." end end
    local pairSeen={}
    local outgoingCounts={}
    for _,item in ipairs(pendingLinks) do
        local fromStep,toStep=indexedSteps[item.from],indexedSteps[item.to]
        if not fromStep or not toStep or fromStep==toStep then return false,"Story Arc connection refers to a missing event." end
        local pair=fromStep.id.."\031"..toStep.id
        if not pairSeen[pair] then
            if Reaches(arc,toStep.id,fromStep.id,{}) then return false,"Story Arc import contains a circular path." end
            outgoingCounts[fromStep.id]=(outgoingCounts[fromStep.id] or 0)+1
            if outgoingCounts[fromStep.id]>MAX_OUTGOING_LINKS then return false,"A Story Arc event has too many outgoing paths." end
            pairSeen[pair]=true
            arc.links[#arc.links+1]={id=NewID("arclink"),from=fromStep.id,to=toStep.id,label=Trim(item.label),order=item.order,createdAt=arc.createdAt}
        end
    end
    local function ResolveBond(item)
        local name=Trim(item and item.name)
        if name~="" and RPAL.EnsureBond then
            local bond=RPAL.EnsureBond(name)
            if bond and bond.key then return tostring(bond.key):lower() end
        end
        return Trim(item and item.key):lower()
    end
    for _,item in ipairs(pendingArcBonds) do arc.bondKeys[#arc.bondKeys+1]=ResolveBond(item) end
    for _,item in ipairs(pendingBonds) do indexedSteps[item.index].bondKeys[#indexedSteps[item.index].bondKeys+1]=ResolveBond(item) end
    arc.rootStepID=(indexedSteps[arc._rootIndex] and indexedSteps[arc._rootIndex].id) or arc.stepOrder[1]
    arc._rootIndex=nil
    local importedCompleted=arc.completed and true or false
    NormalizeArc(arc);SyncProgress(arc)
    arc.completed=importedCompleted;arc.forceOpen=importedCompleted and nil or true;arc.completedAt=importedCompleted and time() or nil
    table.insert(data.arcs,1,arc)
    for _,stepID in ipairs(arc.stepOrder) do
        local location=arc.steps[stepID].location
        if location~="" then RPAL.LearnStoryLocation(location,"import") end
    end
    return true,arc
end

local function SessionPeople(source)
    local names={}
    if type(source.participants)=="table" and RPAL.GetSceneActiveParticipantNames then
        names=RPAL.GetSceneActiveParticipantNames(source)
    elseif type(source.peopleDetails)=="table" then
        for _,person in ipairs(source.peopleDetails) do
            if person.activeRP or person.met or (tonumber(person.interactions) or 0)>0 then
                local name=Trim((person.trpName and person.trpName~="") and person.trpName or person.name)
                if name~="" then names[#names+1]=name end
            end
        end
    else
        for _,name in ipairs(source.people or source.activeParticipantNames or {}) do if Trim(name)~="" then names[#names+1]=Trim(name) end end
    end
    table.sort(names,function(a,b)return a:lower()<b:lower() end)
    return names
end

function RPAL.ConvertSessionToStoryArc(source)
    if type(source)~="table" then return false,"Session could not be found." end
    local title=Trim(source.title)
    if title=="" then title="Session in "..CleanLocation((source.zone and source.zone~="") and source.zone or "Unknown Place") end
    local note=tostring(source.note or source.text or "")
    local ok,arc=RPAL.CreateStoryArc(title,note)
    if not ok then return false,arc end
    arc.steps={};arc.stepOrder={};arc.links={};arc.bondKeys={}
    arc.sourceSessionID=tostring(source.sceneID or source.id or "")

    local bondKeys={}
    for _,name in ipairs(SessionPeople(source)) do
        local bond=RPAL.EnsureBond and RPAL.EnsureBond(name)
        local key=Trim(bond and bond.key or name):lower()
        if key~="" then bondKeys[#bondKeys+1]=key end
    end
    arc.bondKeys=NormalizeBondKeys(bondKeys)

    local locations={}
    for _,location in ipairs(source.locations or {}) do
        local name=CleanLocation((location.customName and location.customName~="") and location.customName or (location.label or location.subZone or location.zone))
        if name~="" and (not locations[#locations] or locations[#locations]~=name) then locations[#locations+1]=name end
    end
    if #locations==0 then locations[1]=CleanLocation((source.zone and source.zone~="") and source.zone or "Unknown Place") end
    local previous
    for index,location in ipairs(locations) do
        local eventTitle=(#locations==1 and title) or (index==1 and ("Arrival — "..location) or ("At "..location))
        local step=NewStep(arc,{
            title=eventTitle,note=index==1 and note or "",location=location,bondKeys=arc.bondKeys,
            x=100+(((index-1)%5)*300),y=150+(math.floor((index-1)/5)*155),completed=true,
            createdAt=tonumber(source.startedAt) or time(),updatedAt=tonumber(source.endedAt or source.at) or time(),
        },index)
        step.bondKeys=NormalizeBondKeys(arc.bondKeys)
        arc.steps[step.id]=step;arc.stepOrder[#arc.stepOrder+1]=step.id
        if not arc.rootStepID then arc.rootStepID=step.id end
        if previous then arc.links[#arc.links+1]={id=NewID("arclink"),from=previous,to=step.id,label="",order=1,createdAt=step.createdAt} end
        previous=step.id
        RPAL.LearnStoryLocation(location,"session")
    end
    SyncProgress(arc);arc.completed=false;arc.forceOpen=true;arc.completedAt=nil
    return true,arc
end

RPAL.STORY_ARC_SCHEMA=ARC_SCHEMA
RPAL.STORY_ARC_BOARD_WIDTH=BOARD_W
RPAL.STORY_ARC_BOARD_HEIGHT=BOARD_H
