local ADDON_NAME, RPAL = ...

-- ============================================================
-- RPAL Chronicle / Scene companion
-- Character-specific by design: scenes, bonds, moments and arcs belong to
-- the current character, just like the Renown Atlas.
-- ============================================================

local MAX_SCENES = 60
local MAX_MOMENTS = 80
local MAX_PINNED_MOMENTS = 3
local MAX_STORED_SCENES = 24
local DEFAULT_ARC_STEPS = 3

-- Shared interaction-map helper. Keep this on RPAL so Scene.lua does not
-- depend on a later-loaded UI.lua local helper when a session is saved.
RPAL.SumMap = RPAL.SumMap or function(map)
    local total = 0
    for _, v in pairs(map or {}) do
        if type(v) == "number" then
            total = total + v
        elseif type(v) == "table" then
            total = total + (tonumber(v.count) or tonumber(v.value) or 0)
        end
    end
    return total
end

local SPARKS = {
    "Maybe I should get some drinks.",
    "Am I... tired?",
    "My shoulders could use a proper stretch.",
    "I wanna eat something, but can't put my finger on it.",
    "What time is it?",
    "My back hurts a bit, I think.",
    "Some extra coins would be nice right now.",
    "A quieter life sounds tempting on some days.",
    "I could use one small task to focus on.",
    "I should take stock of what I am carrying.",
    "I should probably drink some water.",
    "I should make a proper list one of these days.",
    "I should check what I am carrying.",
    "I could go for something warm to eat.",
    "I should check my pockets later.",
    "I keep noticing details I usually pass over.",
    "I think I need to stretch.",
    "Maybe I carry too much stuff around.",
    "A quiet minute would be nice.",
    "My attention keeps wandering today.",
    "I should deal with one of those small repairs soon.",
    "A proper list would make the little tasks easier to remember.",
    "I wonder if I have enough coin on me.",
    "I should finish one of those small chores soon.",
    "Maybe I need a new pair of boots someday.",
    "Some faces are easier to remember than names.",
}
RPAL.SCENE_SPARKS = SPARKS

local function Clamp(v,lo,hi)
    v=tonumber(v) or lo
    if v<lo then return lo end
    if v>hi then return hi end
    return v
end

local function CopyArray(src)
    local out={}
    for i,v in ipairs(src or {}) do out[i]=v end
    return out
end

local function PersonKey(name)
    name=tostring(name or ""):gsub("^%s+",""):gsub("%s+$","")
    return name:lower()
end

local function ShortCharacterName(name)
    name=tostring(name or ""):gsub("^%s+",""):gsub("%s+$","")
    return name:match("^([^%-]+)") or name
end

-- Session participants need two identities: the stable WoW unit name used to
-- match later chat events, and the in-character TRP display name shown in the
-- Chronicle.  Older records only had `name`/`trpName`; normalize those lazily
-- so existing SavedVariables gain the new behaviour without a destructive
-- migration.
local sceneIdentityCache={}
local SCENE_IDENTITY_CACHE_TTL=12
local function RefreshParticipantIdentity(p,fallbackName)
    if type(p)~="table" then return "" end
    fallbackName=ShortCharacterName(fallbackName)
    if not p.unitName or tostring(p.unitName)=="" then
        p.unitName=(fallbackName~="" and fallbackName) or ShortCharacterName(p.name)
    end
    p.unitName=ShortCharacterName(p.unitName)
    local lookup=p.unitName
    local trpName=tostring(p.trpName or "")
    if lookup~="" and RPAL.GetOtherTRPCharacterName then
        local cacheKey=PersonKey(lookup)
        local runtimeNow=(GetTime and GetTime()) or 0
        local cached=sceneIdentityCache[cacheKey]
        local resolved
        if cached and runtimeNow-(cached.at or 0)<SCENE_IDENTITY_CACHE_TTL then
            resolved=cached.value
        else
            resolved=RPAL.GetOtherTRPCharacterName(lookup)
            sceneIdentityCache[cacheKey]={value=resolved or false,at=runtimeNow}
        end
        if resolved and resolved~=false and tostring(resolved)~="" then trpName=tostring(resolved) end
    end
    p.trpName=trpName
    p.name=(trpName~="" and trpName) or (lookup~="" and lookup) or tostring(p.name or "")
    return p.name
end
RPAL.GetSceneParticipantDisplayName=RefreshParticipantIdentity

local function IsActiveRPParticipant(p)
    return type(p)=="table" and (p.activeRP==true or p.met==true or (tonumber(p.interactions) or 0)>0)
end
RPAL.IsSceneParticipantActive=IsActiveRPParticipant

local function PrettyZone()
    local sub=(GetSubZoneText and GetSubZoneText()) or ""
    local zone=(GetZoneText and GetZoneText()) or ""
    if sub~="" then return sub end
    if zone~="" then return zone end
    return "Unknown Place"
end

local function CurrentLocation()
    local zone=(GetZoneText and GetZoneText()) or ""
    local sub=(GetSubZoneText and GetSubZoneText()) or ""
    local mapID=nil
    local x,y=nil,nil
    if C_Map and C_Map.GetBestMapForUnit then
        local ok,id=pcall(C_Map.GetBestMapForUnit,"player")
        if ok then mapID=id end
    end
    if mapID and C_Map and C_Map.GetPlayerMapPosition then
        local ok,pos=pcall(C_Map.GetPlayerMapPosition,mapID,"player")
        if ok and pos and pos.GetXY then
            local okxy,vx,vy=pcall(pos.GetXY,pos)
            if okxy then x,y=tonumber(vx),tonumber(vy) end
        end
    end
    local label=sub~="" and sub or (zone~="" and zone or "Unknown Place")
    local key=tostring(mapID or 0).."|"..tostring(zone).."|"..tostring(sub)
    return {
        key=key,mapID=mapID,zone=zone,subZone=sub,label=label,
        x=x,y=y,firstAt=time(),lastAt=time(),customName="",
    }
end

local function CopyLocations(src)
    local out={}
    for _,loc in ipairs(src or {}) do
        out[#out+1]={
            key=loc.key,mapID=loc.mapID,zone=loc.zone,subZone=loc.subZone,label=loc.label,
            x=loc.x,y=loc.y,firstAt=loc.firstAt,lastAt=loc.lastAt,customName=loc.customName or "",
        }
    end
    return out
end

local function EnsureSceneLocations(scene)
    if not scene then return {} end
    scene.locations=scene.locations or {}
    if #scene.locations==0 then
        local label=tostring(scene.zone or "")
        if label~="" then
            scene.locations[1]={key="legacy|"..label,mapID=nil,zone=label,subZone="",label=label,customName="",firstAt=scene.startedAt,lastAt=scene.endedAt or scene.startedAt}
        end
    end
    return scene.locations
end

function RPAL.GetSceneLocations(scene)
    return EnsureSceneLocations(scene)
end

function RPAL.TrackSceneLocation()
    local scene=RPAL.GetActiveScene and RPAL.GetActiveScene()
    if not scene then return nil end
    local current=CurrentLocation()
    if RPAL.LearnStoryLocation then RPAL.LearnStoryLocation(current.label,"world") end
    local locations=EnsureSceneLocations(scene)
    local last=locations[#locations]
    if last and last.key==current.key then
        local moved=false
        if last.mapID and current.mapID and last.mapID==current.mapID and last.x and last.y and current.x and current.y then
            local dx,dy=current.x-last.x,current.y-last.y
            moved=((dx*dx)+(dy*dy))>=0.0225 -- ~15% of a map; meaningful travel, not jitter.
        end
        if not moved then
            last.lastAt=time()
            return last,false
        end
    end
    locations[#locations+1]=current
    while #locations>24 do table.remove(locations,2) end
    return current,true
end

function RPAL.RenameSceneLocation(sceneID,index,name)
    index=math.floor(tonumber(index) or 0)
    name=tostring(name or ""):gsub("^%s+",""):gsub("%s+$","")
    local d=RPAL.GetRoleplayData and RPAL.GetRoleplayData() or nil
    if not d or index<1 then return false end
    local scene=(d.activeScene and d.activeScene.id==sceneID) and d.activeScene or nil
    if not scene then
        for _,s in ipairs(d.scenes or {}) do if s.id==sceneID then scene=s break end end
    end
    if not scene then return false end
    local locations=EnsureSceneLocations(scene)
    if not locations[index] then return false end
    locations[index].customName=name
    if name~="" and RPAL.LearnStoryLocation then RPAL.LearnStoryLocation(name,"session-custom") end
    if RPAL.SyncSavedSessionFromScene then RPAL.SyncSavedSessionFromScene(scene,scene.title,scene.note) end
    return true,locations[index]
end

local function GetRoleplayData()
    if not RPAL.char then return nil end
    if RPAL.SyncTRPProfileScope and not RPAL._syncingTRPProfileScope then
        RPAL._syncingTRPProfileScope=true
        RPAL.SyncTRPProfileScope(false)
        RPAL._syncingTRPProfileScope=false
    end
    RPAL.char.roleplay = RPAL.char.roleplay or {}
    local d=RPAL.char.roleplay
    d.version=math.max(2,tonumber(d.version) or 1)
    d.scenes=d.scenes or {}
    d.pausedScenes=d.pausedScenes or {}
    d.bonds=d.bonds or {}
    d.moments=d.moments or {}
    d.arcs=d.arcs or {}
    d.activeScene=d.activeScene
    d.spark=d.spark or {enabled=true}
    if d.spark.enabled==nil then d.spark.enabled=true end
    return d
end
RPAL.GetRoleplayData=GetRoleplayData

function RPAL.IsSceneActive()
    local d=GetRoleplayData()
    return d and type(d.activeScene)=="table"
end

function RPAL.GetActiveScene()
    local d=GetRoleplayData()
    return d and d.activeScene or nil
end

local function TouchParticipant(name)
    local scene=RPAL.GetActiveScene()
    name=ShortCharacterName(name)
    local key=PersonKey(name)
    if not scene or key=="" then return nil end
    scene.participants=scene.participants or {}
    local p=scene.participants[key]
    if not p then
        p={name=name,unitName=name,firstAt=time(),lastAt=time(),interactions=0,met=false,presence=false,activeRP=false,trpName=""}
        scene.participants[key]=p
    else
        if name~="" then p.unitName=name end
        p.lastAt=time()
    end
    RefreshParticipantIdentity(p,name)
    return p
end
RPAL.SceneTouchParticipant=TouchParticipant

local function MarkActiveRPParticipant(name,reason)
    local shareTarget=tostring(name or "")
    local p=TouchParticipant(name)
    if not p then return nil end
    p.presence=true
    p.activeRP=true
    p.activeFirstAt=tonumber(p.activeFirstAt) or time()
    p.activeLastAt=time()
    reason=tostring(reason or "direct-roleplay")
    if not p.activeSource or p.activeSource=="" then p.activeSource=reason end
    if shareTarget~="" and RPAL.ShareStoryLocationsWith then RPAL.ShareStoryLocationsWith(shareTarget) end
    if shareTarget~="" and RPAL.TryGrantDailyRPReward then RPAL.TryGrantDailyRPReward(shareTarget,reason) end
    return p
end
RPAL.SceneMarkActiveParticipant=MarkActiveRPParticipant

local function TrackScenePresenceUnit(unit)
    if not UnitExists or not UnitExists(unit) then return end
    if UnitIsUnit and UnitIsUnit(unit,"player") then return end
    if UnitIsPlayer and not UnitIsPlayer(unit) then return end
    local name=UnitName and UnitName(unit)
    if not name or name=="" then return end
    local p=TouchParticipant(name)
    if p then p.presence=true end
end

local function TrackScenePresence()
    if not RPAL.IsSceneActive or not RPAL.IsSceneActive() then return end
    TrackScenePresenceUnit("target")
    TrackScenePresenceUnit("focus")
    TrackScenePresenceUnit("mouseover")
    if IsInRaid and IsInRaid() then
        for i=1,40 do TrackScenePresenceUnit("raid"..i) end
    elseif IsInGroup and IsInGroup() then
        for i=1,4 do TrackScenePresenceUnit("party"..i) end
    end
end

-- Presence sampling does not need a per-frame OnUpdate.  Prefer the timer
-- scheduler so inactive/long-running RPAL sessions do less frame work.
if RPAL._scenePresenceTicker and RPAL._scenePresenceTicker.Cancel then RPAL._scenePresenceTicker:Cancel() end
RPAL._scenePresenceTicker=nil
if C_Timer and C_Timer.NewTicker then
    RPAL._scenePresenceTicker=C_Timer.NewTicker(6,TrackScenePresence)
else
    RPAL._scenePresenceFrame=RPAL._scenePresenceFrame or CreateFrame("Frame")
    RPAL._scenePresenceFrame._elapsed=0
    RPAL._scenePresenceFrame:SetScript("OnUpdate",function(self,elapsed)
        self._elapsed=(self._elapsed or 0)+(elapsed or 0)
        if self._elapsed<6 then return end
        self._elapsed=0
        TrackScenePresence()
    end)
end

local function UpdateActiveSceneClock()
    local d=GetRoleplayData()
    local scene=d and d.activeScene
    if not scene then RPAL._sceneRuntimeMark=nil return end
    local now=(GetTime and GetTime()) or 0
    if not RPAL._sceneRuntimeMark then RPAL._sceneRuntimeMark=now return end
    local delta=math.max(0,now-RPAL._sceneRuntimeMark)
    -- Ignore pathological jumps from a reset runtime clock; normal in-session
    -- time is accumulated, offline time is deliberately not.
    if delta<120 then scene.activeSeconds=(tonumber(scene.activeSeconds) or 0)+delta end
    RPAL._sceneRuntimeMark=now
end

local function SceneDuration(scene)
    if not scene then return 0 end
    local active=RPAL.GetActiveScene and RPAL.GetActiveScene()
    if active==scene then UpdateActiveSceneClock() end
    if scene.duration~=nil then return math.max(0,tonumber(scene.duration) or 0) end
    if scene.activeSeconds~=nil then return math.max(0,tonumber(scene.activeSeconds) or 0) end
    local finish=tonumber(scene.endedAt) or time()
    return math.max(0,finish-(tonumber(scene.startedAt) or finish))
end
RPAL.GetSceneDuration=SceneDuration

function RPAL.StartScene()
    local d=GetRoleplayData()
    if not d then return false,"Character data is not ready." end
    if d.activeScene then return false,"A Session is already in progress." end
    local now=time()
    d.activeScene={
        id=RPAL.NewID and RPAL.NewID("scene") or ("scene-"..tostring(now)),
        title="",
        note="",
        startedAt=now,
        activeSeconds=0,
        zone=PrettyZone(),
        locations={CurrentLocation()},
        participants={},
        interactions={},
        renownGained=0,
        renownSources={},
        talentProcs={},
        newCharacters={},
        metCharacters={},
    }
    RPAL._sceneRuntimeMark=(GetTime and GetTime()) or 0
    if RPAL.ResetSessionStartPromptState then RPAL.ResetSessionStartPromptState() end
    if RPAL.ScheduleNextSceneSpark then RPAL.ScheduleNextSceneSpark(true) end
    if RPAL.StartSessionActivityMonitor then RPAL.StartSessionActivityMonitor() end
    if RPAL.RefreshSceneButtons then RPAL.RefreshSceneButtons() end
    if RPAL.OnSceneStarted then RPAL.OnSceneStarted(d.activeScene) end
    return true,d.activeScene
end

local function SortedParticipantNames(scene)
    local names={}
    for _,p in pairs((scene and scene.participants) or {}) do
        local name=RefreshParticipantIdentity(p,p.unitName or p.name)
        if name~="" then names[#names+1]=name end
    end
    table.sort(names,function(a,b)return tostring(a):lower()<tostring(b):lower() end)
    return names
end
RPAL.GetSceneParticipantNames=SortedParticipantNames

local function SortedActiveParticipantNames(scene)
    local names={}
    for _,p in pairs((scene and scene.participants) or {}) do
        if IsActiveRPParticipant(p) then
            local name=RefreshParticipantIdentity(p,p.unitName or p.name)
            if name~="" then names[#names+1]=name end
        end
    end
    table.sort(names,function(a,b)return tostring(a):lower()<tostring(b):lower() end)
    return names
end
RPAL.GetSceneActiveParticipantNames=SortedActiveParticipantNames

function RPAL.GetSceneParticipantCounts(scene)
    local active,total=0,0
    for _,p in pairs((scene and scene.participants) or {}) do
        total=total+1
        if IsActiveRPParticipant(p) then active=active+1 end
    end
    return active,total
end

local function RefreshSceneParticipantLists(scene)
    if not scene then return end
    scene.participantNames=SortedParticipantNames(scene)
    scene.activeParticipantNames=SortedActiveParticipantNames(scene)
end

function RPAL.GetStoredScenes()
    local d=GetRoleplayData()
    return d and d.pausedScenes or {}
end

function RPAL.GetStoredSceneCount()
    return #(RPAL.GetStoredScenes())
end

-- Pause removes the Session from live tracking without finalizing it. Renown,
-- participants, route, interactions and the active-duration clock stay on the
-- same object so resuming later continues the original record instead of
-- creating or merging a second Session.
function RPAL.PauseAndStoreScene(reason)
    local d=GetRoleplayData()
    if not d or not d.activeScene then return false,"No Session is in progress." end
    if #d.pausedScenes>=MAX_STORED_SCENES then
        return false,"Stored Sessions are full. Resume or discard one before pausing another."
    end

    local scene=d.activeScene
    local pauseReason=(tostring(reason or ""):lower()=="ooc") and "ooc" or "manual"
    UpdateActiveSceneClock()
    if RPAL.TrackSceneLocation then RPAL.TrackSceneLocation() end
    scene.pausedAt=time()
    scene.pauseCount=(tonumber(scene.pauseCount) or 0)+1
    scene.pauseReason=pauseReason
    scene.pausedByOOC=(pauseReason=="ooc") and true or nil
    scene.awaitingICDecision=(pauseReason=="ooc") and true or nil
    scene.duration=nil
    scene.endedAt=nil
    scene.zone=scene.zone or PrettyZone()
    RefreshSceneParticipantLists(scene)

    d.activeScene=nil
    table.insert(d.pausedScenes,1,scene)
    RPAL._sceneRuntimeMark=nil
    RPAL._sceneSparkNextAt=nil
    if RPAL.StopSessionActivityMonitor then RPAL.StopSessionActivityMonitor() end
    if RPAL.CloseSessionActivityPrompt then RPAL.CloseSessionActivityPrompt() end
    if RPAL.RefreshSceneButtons then RPAL.RefreshSceneButtons() end
    if RPAL.OnScenePaused then RPAL.OnScenePaused(scene) end
    return true,scene
end

function RPAL.ResumeStoredScene(sceneID)
    local d=GetRoleplayData()
    if not d then return false,"Character data is not ready." end
    if d.activeScene then return false,"Pause or end the active Session before resuming another." end

    sceneID=tostring(sceneID or "")
    local storedIndex,scene=nil,nil
    for index,item in ipairs(d.pausedScenes) do
        if tostring(item.id or "")==sceneID then storedIndex=index;scene=item;break end
    end
    if not scene then return false,"Stored Session could not be found." end

    table.remove(d.pausedScenes,storedIndex)
    scene.lastPausedAt=scene.pausedAt
    scene.lastPauseReason=scene.pauseReason
    scene.pausedAt=nil
    scene.pauseReason=nil
    scene.pausedByOOC=nil
    scene.awaitingICDecision=nil
    scene.lastResumedAt=time()
    scene.resumeCount=(tonumber(scene.resumeCount) or 0)+1
    scene.duration=nil
    scene.endedAt=nil
    d.activeScene=scene
    RPAL._sceneRuntimeMark=(GetTime and GetTime()) or 0
    if RPAL.ResetSessionStartPromptState then RPAL.ResetSessionStartPromptState() end
    if RPAL.TrackSceneLocation then RPAL.TrackSceneLocation() end
    if RPAL.ScheduleNextSceneSpark then RPAL.ScheduleNextSceneSpark(true) end
    if RPAL.StartSessionActivityMonitor then RPAL.StartSessionActivityMonitor() end
    if RPAL.RefreshSceneButtons then RPAL.RefreshSceneButtons() end
    if RPAL.OnSceneResumed then RPAL.OnSceneResumed(scene) end
    return true,scene
end

function RPAL.GetPendingICSessionScene()
    for _,scene in ipairs(RPAL.GetStoredScenes()) do
        if scene.awaitingICDecision and scene.pauseReason=="ooc" then return scene end
    end
    return nil
end

-- Resolve the one decision created by an OOC Pause & Store. Continuing removes
-- the stored record and resumes that exact object; starting new leaves it in
-- Paused Sessions but clears the one-shot IC question.
function RPAL.ResolveICSessionChoice(sceneID,choice)
    local d=GetRoleplayData()
    if not d then return false,"Character data is not ready." end
    if d.activeScene then return false,"A Session is already in progress." end

    sceneID=tostring(sceneID or "")
    choice=tostring(choice or ""):lower()
    local pending
    for _,scene in ipairs(d.pausedScenes or {}) do
        if tostring(scene.id or "")==sceneID and scene.awaitingICDecision then pending=scene;break end
    end
    if not pending then return false,"The paused Session is no longer waiting for a decision." end

    if choice=="continue" or choice=="resume" then
        local ok,scene=RPAL.ResumeStoredScene(sceneID)
        if ok and type(scene)=="table" then scene.resumedByTRPStatus=true end
        return ok,scene
    end
    if choice=="new" or choice=="start" then
        local ok,scene=RPAL.StartScene()
        if not ok then return false,scene end
        pending.awaitingICDecision=nil
        pending.lastICDecision="new"
        pending.lastICDecisionAt=time()
        if type(scene)=="table" then scene.startedByTRPStatus=true end
        return true,scene
    end
    return false,"Unknown Session choice."
end

function RPAL.DeleteStoredScene(sceneID)
    local d=GetRoleplayData()
    if not d then return false end
    sceneID=tostring(sceneID or "")
    for index,scene in ipairs(d.pausedScenes) do
        if tostring(scene.id or "")==sceneID then
            table.remove(d.pausedScenes,index)
            return true,scene
        end
    end
    return false
end

local function UpdateBondsFromScene(scene)
    local d=GetRoleplayData()
    if not d or not scene then return end
    d.bonds=d.bonds or {}
    local ended=tonumber(scene.endedAt) or time()
    for key,p in pairs(scene.participants or {}) do
        local displayName=RefreshParticipantIdentity(p,p.unitName or p.name)
        local bond=d.bonds[key]
        if not bond then
            bond={
                key=key,
                name=(displayName~="" and displayName) or key,
                unitName=p.unitName or "",
                trpName=p.trpName or "",
                relationship="Acquaintance",
                note="",
                firstSeen=tonumber(p.firstAt) or tonumber(scene.startedAt) or ended,
                lastSeen=ended,
                firstZone=scene.zone or "",
                scenesTogether=0,
                interactions=0,
            }
            d.bonds[key]=bond
        end
        bond.name=(displayName~="" and displayName) or bond.name
        bond.unitName=p.unitName or bond.unitName
        bond.trpName=p.trpName or bond.trpName
        bond.lastSeen=ended
        bond.scenesTogether=(tonumber(bond.scenesTogether) or 0)+1
        bond.interactions=(tonumber(bond.interactions) or 0)+(tonumber(p.interactions) or 0)
        if not bond.firstSeen then bond.firstSeen=tonumber(scene.startedAt) or ended end
        if not bond.firstZone or bond.firstZone=="" then bond.firstZone=scene.zone or "" end
    end
end

function RPAL.EndStoredScene(sceneID)
    local d=GetRoleplayData()
    if not d then return false,"Character data is not ready." end
    sceneID=tostring(sceneID or "")
    local storedIndex,scene
    for index,item in ipairs(d.pausedScenes or {}) do
        if tostring(item.id or "")==sceneID then storedIndex=index;scene=item;break end
    end
    if not scene then return false,"Paused Session could not be found." end

    table.remove(d.pausedScenes,storedIndex)
    scene.lastPausedAt=scene.pausedAt or scene.lastPausedAt
    scene.lastPauseReason=scene.pauseReason or scene.lastPauseReason
    scene.pausedAt=nil
    scene.pauseReason=nil
    scene.pausedByOOC=nil
    scene.awaitingICDecision=nil
    scene.endedAt=time()
    scene.duration=math.max(0,tonumber(scene.activeSeconds) or tonumber(scene.duration) or 0)
    RefreshSceneParticipantLists(scene)
    scene.zone=scene.zone or PrettyZone()
    if not scene.title or scene.title=="" then scene.title="A Session in "..tostring(scene.zone or "Unknown Place") end

    UpdateBondsFromScene(scene)
    table.insert(d.scenes,1,scene)
    while #d.scenes>MAX_SCENES do table.remove(d.scenes) end
    if RPAL.ConsumeLegacyCatchupSession then RPAL.ConsumeLegacyCatchupSession(scene) end
    if RPAL.ConsumeRenownCurveCatchupSession then RPAL.ConsumeRenownCurveCatchupSession(scene) end
    if RPAL.RefreshSceneButtons then RPAL.RefreshSceneButtons() end
    if RPAL.OnSceneEnded then RPAL.OnSceneEnded(scene) end
    return true,scene
end

function RPAL.EndScene()
    local d=GetRoleplayData()
    if not d or not d.activeScene then return false,"No Session is in progress." end
    local scene=d.activeScene
    UpdateActiveSceneClock()
    if RPAL.TrackSceneLocation then RPAL.TrackSceneLocation() end
    scene.endedAt=time()
    scene.duration=math.max(0,tonumber(scene.activeSeconds) or 0)
    d.activeScene=nil
    RPAL._sceneRuntimeMark=nil
    RefreshSceneParticipantLists(scene)
    scene.zone=scene.zone or PrettyZone()
    if not scene.title or scene.title=="" then
        scene.title="A Session in "..tostring(scene.zone or "Unknown Place")
    end
    UpdateBondsFromScene(scene)
    table.insert(d.scenes,1,scene)
    while #d.scenes>MAX_SCENES do table.remove(d.scenes) end
    if RPAL.ConsumeLegacyCatchupSession then RPAL.ConsumeLegacyCatchupSession(scene) end
    if RPAL.ConsumeRenownCurveCatchupSession then RPAL.ConsumeRenownCurveCatchupSession(scene) end
    RPAL._sceneSparkNextAt=nil
    if RPAL.StopSessionActivityMonitor then RPAL.StopSessionActivityMonitor() end
    if RPAL.CloseSessionActivityPrompt then RPAL.CloseSessionActivityPrompt() end
    if RPAL.RefreshSceneButtons then RPAL.RefreshSceneButtons() end
    if RPAL.OnSceneEnded then RPAL.OnSceneEnded(scene) end
    return true,scene
end

-- ------------------------------------------------------------
-- Total RP 3 status -> Session lifecycle
-- ------------------------------------------------------------
-- Only stable IC/OOC states participate. UNKNOWN and LFC are ignored so a
-- brief profile-cache gap cannot accidentally stop or restart a Session.
RPAL.SESSION_STATUS_MESSAGES=RPAL.SESSION_STATUS_MESSAGES or {
    started="Session record started, enjoy the RP! <3",
    askStop="Would you like to stop recording session since you gone out of character(OOC)?",
    resumeChoice="Would you like to start a new session, or continue from the last paused session?",
}
RPAL.SESSION_STATUS_MESSAGES.resumeChoice=RPAL.SESSION_STATUS_MESSAGES.resumeChoice or "Would you like to start a new session, or continue from the last paused session?"

function RPAL.CheckTRPSessionStatus(force)
    if RPAL.SyncTRPProfileScope then RPAL.SyncTRPProfileScope(false) end
    if type(RPAL.GetTRPCharacterStatus)~="function" then return false,"TRP status is unavailable." end
    local status=select(1,RPAL.GetTRPCharacterStatus())
    if status~="IC" and status~="OOC" then return false,status or "UNKNOWN" end

    local previous=RPAL._lastSessionTRPStatus
    if previous==status and not force then return false,"unchanged" end
    RPAL._lastSessionTRPStatus=status

    if status=="IC" then
        RPAL._oocSessionPrompted=false
        if RPAL.CloseOOCSessionPrompt then RPAL.CloseOOCSessionPrompt() end
        if not (RPAL.IsSceneActive and RPAL.IsSceneActive()) then
            local pending=RPAL.GetPendingICSessionScene and RPAL.GetPendingICSessionScene()
            if pending then
                if RPAL.PromptICSessionChoice then
                    RPAL.PromptICSessionChoice(pending,RPAL.SESSION_STATUS_MESSAGES.resumeChoice)
                    return true,"choice"
                end
                return false,"choice unavailable"
            end
            local stored=RPAL.GetStoredScenes and RPAL.GetStoredScenes() or {}
            if stored[1] and RPAL.ResumeStoredScene then
                local resumed,resumedScene=RPAL.ResumeStoredScene(stored[1].id)
                if resumed then
                    if type(resumedScene)=="table" then resumedScene.resumedByTRPStatus=true end
                    return true,"resumed"
                end
            end
            local ok,scene=RPAL.StartScene()
            if ok then
                if type(scene)=="table" then scene.startedByTRPStatus=true end
                if RPAL.ShowSessionStatusToast then
                    RPAL.ShowSessionStatusToast(RPAL.SESSION_STATUS_MESSAGES.started,"IC")
                else
                    print("|cff88dd88ODYN:|r "..RPAL.SESSION_STATUS_MESSAGES.started)
                end
                return true,"started"
            end
            return false,scene
        end
        return false,"already active"
    end

    if RPAL.CloseICSessionChoicePrompt then RPAL.CloseICSessionChoicePrompt() end
    if RPAL.CloseSessionStartPrompt then RPAL.CloseSessionStartPrompt() end
    if RPAL.CloseSessionActivityPrompt then RPAL.CloseSessionActivityPrompt() end
    if previous=="IC" and RPAL.IsSceneActive and RPAL.IsSceneActive() and not RPAL._oocSessionPrompted then
        if RPAL.PromptStopSessionForOOC then
            RPAL._oocSessionPrompted=true
            RPAL.PromptStopSessionForOOC(RPAL.SESSION_STATUS_MESSAGES.askStop)
            return true,"prompted"
        end
        return false,"prompt unavailable"
    end
    return false,"no active transition"
end

function RPAL.StartTRPSessionStatusMonitor()
    if RPAL._trpSessionStatusTicker then return RPAL._trpSessionStatusTicker end

    local function Check(force)
        if not RPAL.char then return end
        RPAL.CheckTRPSessionStatus(force)
        if RPAL.IsSceneActive and RPAL.IsSceneActive() and RPAL.StartSessionActivityMonitor then RPAL.StartSessionActivityMonitor() end
    end

    if C_Timer and C_Timer.After then C_Timer.After(0.45,function()Check(true)end)
    else Check(true) end

    if C_Timer and C_Timer.NewTicker then
        RPAL._trpSessionStatusTicker=C_Timer.NewTicker(2.5,function()Check(false)end)
    end
    return RPAL._trpSessionStatusTicker
end

function RPAL.StopTRPSessionStatusMonitor()
    if RPAL._trpSessionStatusTicker and RPAL._trpSessionStatusTicker.Cancel then
        RPAL._trpSessionStatusTicker:Cancel()
    end
    RPAL._trpSessionStatusTicker=nil
    RPAL._lastSessionTRPStatus=nil
    RPAL._oocSessionPrompted=false
    if RPAL.CloseOOCSessionPrompt then RPAL.CloseOOCSessionPrompt() end
    if RPAL.CloseICSessionChoicePrompt then RPAL.CloseICSessionChoicePrompt() end
    if RPAL.CloseSessionStartPrompt then RPAL.CloseSessionStartPrompt() end
end

-- ------------------------------------------------------------
-- Non-blocking Session side-card guards
-- ------------------------------------------------------------
RPAL.SESSION_ACTIVITY_MESSAGE="Are you still RPing? If not, better stop the session!"
RPAL.SESSION_START_MESSAGE="You seem to be roleplaying with someone. Start a Session?"
local activityState={}
local activitySuppressedSceneID
local activityCooldownSceneID
local activityCooldownUntil=0
local startPromptState={cooldownUntil=0,suppressUntilSession=false,lastPromptAt=-999}

local function ActivityNow()
    return (GetTime and GetTime()) or 0
end

function RPAL.ResetSessionStartPromptState()
    startPromptState.cooldownUntil=0
    startPromptState.suppressUntilSession=false
    startPromptState.lastPromptAt=-999
    if RPAL.CloseSessionStartPrompt then RPAL.CloseSessionStartPrompt() end
    return true
end

function RPAL.AcknowledgeSessionStartPrompt(hideUntilNextSession)
    local now=ActivityNow()
    if hideUntilNextSession then
        startPromptState.suppressUntilSession=true
        startPromptState.cooldownUntil=0
    else
        startPromptState.suppressUntilSession=false
        startPromptState.cooldownUntil=now+600
    end
    return true
end

function RPAL.MaybePromptSessionStartForInteraction(otherName,reason)
    if RPAL.IsSceneActive and RPAL.IsSceneActive() then return false,"already active" end
    if RPAL.IsTRPInCharacter and not RPAL.IsTRPInCharacter() then return false,"not IC" end
    if RPAL.GetPendingICSessionScene and RPAL.GetPendingICSessionScene() then return false,"paused choice pending" end
    if startPromptState.suppressUntilSession then return false,"suppressed" end
    local now=ActivityNow()
    if now<(tonumber(startPromptState.cooldownUntil) or 0) then return false,"cooldown" end
    if now-(tonumber(startPromptState.lastPromptAt) or -999)<3 then return false,"recent" end
    startPromptState.lastPromptAt=now
    if RPAL.ShowSessionStartPrompt then
        RPAL.ShowSessionStartPrompt(otherName,reason,RPAL.SESSION_START_MESSAGE)
        return true
    end
    return false,"prompt unavailable"
end

function RPAL.AcknowledgeSessionActivityPrompt(seconds,hideUntilNextSession)
    local now=ActivityNow()
    if hideUntilNextSession then
        activitySuppressedSceneID=activityState.sceneID
        activityCooldownSceneID=nil
        activityCooldownUntil=0
    else
        activitySuppressedSceneID=nil
        activityCooldownSceneID=activityState.sceneID
        activityCooldownUntil=now+math.max(30,tonumber(seconds) or 600)
    end
    activityState.mountedSince=nil;activityState.jumpTimes={}
    return true
end

function RPAL.StopSessionActivityMonitor()
    if RPAL._sessionActivityTicker and RPAL._sessionActivityTicker.Cancel then RPAL._sessionActivityTicker:Cancel() end
    RPAL._sessionActivityTicker=nil
    activityState={}
    if RPAL.CloseSessionActivityPrompt then RPAL.CloseSessionActivityPrompt() end
end

local function PromptForSessionActivity(reason)
    local now=ActivityNow()
    if activitySuppressedSceneID and tostring(activitySuppressedSceneID)==tostring(activityState.sceneID) then return false end
    if activityCooldownSceneID and tostring(activityCooldownSceneID)==tostring(activityState.sceneID) and now<(tonumber(activityCooldownUntil) or 0) then return false end
    if now-(tonumber(activityState.lastPromptAt) or -999)<15 then return false end
    activityState.lastPromptAt=now
    if RPAL.ShowSessionActivityPrompt then RPAL.ShowSessionActivityPrompt(reason,RPAL.SESSION_ACTIVITY_MESSAGE) end
    return true
end

local function CheckSessionActivity()
    local scene=RPAL.GetActiveScene and RPAL.GetActiveScene() or nil
    if not scene then RPAL.StopSessionActivityMonitor();return end
    if activityState.sceneID~=scene.id then activityState={sceneID=scene.id,jumpTimes={}} end

    local now=ActivityNow()
    if RPAL.IsTRPInCharacter and not RPAL.IsTRPInCharacter() then
        activityState.mountedSince=nil;activityState.wasFalling=false;activityState.jumpTimes={}
        return
    end
    local mounted=(IsMounted and IsMounted()) or false
    local taxi=(UnitOnTaxi and UnitOnTaxi("player")) or false
    local flying=(IsFlying and IsFlying()) or false
    local gliding=(C_PlayerInfo and C_PlayerInfo.IsGliding and C_PlayerInfo.IsGliding()) or false
    local airborneMount=(mounted and (flying or gliding)) or taxi

    if airborneMount then
        activityState.mountedSince=activityState.mountedSince or now
        if now-activityState.mountedSince>=1.2 then PromptForSessionActivity("Flying while mounted detected.") end
    else
        activityState.mountedSince=nil
    end

    -- Normal running (including running while IC) is intentionally ignored.
    -- Four separate airborne starts inside ten seconds is treated as jump spam;
    -- ordinary falls/ledge drops are very unlikely to hit that threshold.
    if not mounted and not taxi then
        local falling=(IsFalling and IsFalling()) and true or false
        if falling and not activityState.wasFalling then
            local jumps=activityState.jumpTimes or {};activityState.jumpTimes=jumps
            jumps[#jumps+1]=now
            for i=#jumps,1,-1 do if now-jumps[i]>10 then table.remove(jumps,i) end end
            if #jumps>=4 then PromptForSessionActivity("Repeated jumping detected (4 jumps in 10 seconds).");activityState.jumpTimes={} end
        end
        activityState.wasFalling=falling
    else
        activityState.wasFalling=false
        activityState.jumpTimes={}
    end
end

function RPAL.StartSessionActivityMonitor()
    local scene=RPAL.GetActiveScene and RPAL.GetActiveScene() or nil
    if not scene then return false,"No Session is in progress." end
    if RPAL._sessionActivityTicker then
        activityState.sceneID=scene.id
        return RPAL._sessionActivityTicker
    end
    activityState={sceneID=scene.id,jumpTimes={}}
    if C_Timer and C_Timer.NewTicker then
        RPAL._sessionActivityTicker=C_Timer.NewTicker(0.75,CheckSessionActivity)
        return RPAL._sessionActivityTicker
    end
    return false,"Timer API is unavailable."
end

function RPAL.SceneRecordRenown(amount,source)
    local scene=RPAL.GetActiveScene()
    amount=math.max(0,math.floor(tonumber(amount) or 0))
    if not scene or amount<=0 then return end
    scene.renownGained=(tonumber(scene.renownGained) or 0)+amount
    source=tostring(source or "Renown")
    scene.renownSources=scene.renownSources or {}
    scene.renownSources[source]=(tonumber(scene.renownSources[source]) or 0)+amount
end

function RPAL.SceneRecordInteraction(key,actor)
    local scene=RPAL.GetActiveScene()
    if not scene then return end
    key=tostring(key or "")
    if key~="" then
        scene.interactions=scene.interactions or {}
        scene.interactions[key]=(tonumber(scene.interactions[key]) or 0)+1
    end
    local p=MarkActiveRPParticipant(actor,"tracked-interaction")
    if p then p.interactions=(tonumber(p.interactions) or 0)+1 end
end

function RPAL.SceneRecordCharacterMet(name,isNew)
    local scene=RPAL.GetActiveScene()
    if not scene then return end
    local p=MarkActiveRPParticipant(name,"character-met")
    if p then
        p.met=true
        scene.metCharacters=scene.metCharacters or {}
        scene.metCharacters[PersonKey(name)]=p.name or tostring(name or "")
    end
    if isNew and p then
        scene.newCharacters=scene.newCharacters or {}
        scene.newCharacters[PersonKey(name)]=p.name or tostring(name or "")
    end
end

function RPAL.SceneRecordTalentProc(title,amount)
    local scene=RPAL.GetActiveScene()
    if not scene then return end
    scene.talentProcs=scene.talentProcs or {}
    scene.talentProcs[#scene.talentProcs+1]={title=tostring(title or "Talent"),amount=math.max(0,math.floor(tonumber(amount) or 0)),at=time()}
end

function RPAL.UpdateScene(sceneID,fields)
    local d=GetRoleplayData()
    if not d then return false end
    local target
    if d.activeScene and d.activeScene.id==sceneID then target=d.activeScene end
    if not target then
        for _,scene in ipairs(d.scenes or {}) do if scene.id==sceneID then target=scene break end end
    end
    if not target then return false end
    for k,v in pairs(fields or {}) do target[k]=v end
    if RPAL.SyncSavedSessionFromScene then RPAL.SyncSavedSessionFromScene(target,target.title,target.note) end
    return true,target
end

function RPAL.GetScenes()
    local d=GetRoleplayData()
    return d and d.scenes or {}
end

-- Scene History is a private automatic log. Removing a history entry does
-- not undo Renown, Bonds, Moments, Talents, or any other progression that
-- was earned while the scene was active.
function RPAL.DeleteScene(sceneID)
    local d=GetRoleplayData()
    if not d or not sceneID then return false end
    for i,scene in ipairs(d.scenes or {}) do
        if scene.id==sceneID then
            table.remove(d.scenes,i)
            return true
        end
    end
    return false
end

function RPAL.GetBonds()
    local d=GetRoleplayData()
    if not d then return {} end
    d.bonds=d.bonds or {}

    -- Characters Met is ODYN's authoritative social ledger.  Use it to seed
    -- missing Bond rows, then include older Session-derived Bonds that already
    -- contain real interaction counters. Never mirror nearby-only Session actors
    -- or the TRP directory.
    local stats=RPAL.GetStatistics and RPAL.GetStatistics() or {}
    local metNames={}
    local function CleanKey(value)
        value=tostring(value or ""):gsub("^%s+",""):gsub("%s+$",""):lower()
        return value
    end
    local function MarkName(value)
        local key=CleanKey(value)
        if key=="" then return end
        metNames[key]=true
        local short=key:match("^([^%-]+)")
        if short and short~="" then metNames[short]=true end
    end
    for metKey,entry in pairs(type(stats.metPlayers)=="table" and stats.metPlayers or {}) do
        if type(entry)=="table" and CleanKey(entry.name)~="" then
            local currentKey=CleanKey(entry.name)
            local currentShort=currentKey:match("^([^%-]+)") or currentKey
            MarkName(entry.name)
            local found
            for _,bond in pairs(d.bonds) do
                for _,value in ipairs({bond.name,bond.unitName,bond.trpName}) do
                    local candidate=CleanKey(value)
                    local short=candidate:match("^([^%-]+)") or candidate
                    if candidate==currentKey or candidate==currentShort or short==currentKey or short==currentShort then
                        found=bond
                        break
                    end
                end
                if found then break end
            end
            if not found then
                local name=tostring(entry.name)
                local key=PersonKey(name)
                if key~="" and not d.bonds[key] then
                    d.bonds[key]={key=key,name=name,relationship="Acquaintance",note="",firstSeen=tonumber(entry.firstMetAt) or time(),lastSeen=tonumber(entry.firstMetAt) or time(),firstZone="",scenesTogether=0,interactions=1,_fromCharactersMet=true}
                end
            end
        end
    end

    local list={}
    for _,bond in pairs(d.bonds) do
        local interacted=(tonumber(bond.interactions) or 0)>0
        local met=false
        for _,value in ipairs({bond.name,bond.unitName,bond.trpName}) do
            local key=CleanKey(value);local short=key:match("^([^%-]+)")
            if metNames[key] or (short and metNames[short]) then met=true break end
        end
        if interacted or met then list[#list+1]=bond end
    end
    table.sort(list,function(a,b)
        local al=tonumber(a.lastSeen) or 0
        local bl=tonumber(b.lastSeen) or 0
        if al==bl then return tostring(a.name or ""):lower()<tostring(b.name or ""):lower() end
        return al>bl
    end)
    return list
end

function RPAL.EnsureBond(name)
    local d=GetRoleplayData()
    if not d then return nil end
    local key=PersonKey(name)
    if key=="" then return nil end
    d.bonds=d.bonds or {}
    local bond=d.bonds[key]
    if not bond then
        bond={key=key,name=tostring(name),relationship="Acquaintance",note="",firstSeen=time(),lastSeen=time(),firstZone=PrettyZone(),scenesTogether=0,interactions=0}
        d.bonds[key]=bond
    end
    return bond
end

function RPAL.UpdateBond(name,relationship,note)
    local bond=RPAL.EnsureBond(name)
    if not bond then return false end
    if relationship~=nil and tostring(relationship)~="" then bond.relationship=tostring(relationship);bond.relationshipSource="RPAL" end
    if note~=nil then bond.note=tostring(note) end
    bond.lastSeen=tonumber(bond.lastSeen) or time()
    return true,bond
end

local function CleanTRPName(value)
    value=tostring(value or ""):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]"," "):gsub("%s+"," ")
    return value:gsub("^%s+",""):gsub("%s+$","")
end

local function TRPProfileName(profile,unitID)
    local characteristics=type(profile)=="table" and profile.characteristics or nil
    local first=characteristics and CleanTRPName(characteristics.FN) or ""
    local last=characteristics and CleanTRPName(characteristics.LN) or ""
    local rpName=CleanTRPName(first..((first~="" and last~="") and " " or "")..last)
    if rpName~="" then return rpName,first,true end
    -- Bonds may still use the WoW unit name as a technical fallback, but Persona
    -- must never mistake that fallback for a roleplay identity.
    return CleanTRPName(ShortCharacterName(unitID)),"",false
end

function RPAL.SyncTRPRelationsToBonds(force)
    if RPAL._syncingTRPBonds then return 0 end
    if not TRP3_API or not TRP3_API.register or not TRP3_API.register.relation then return 0 end
    local now=(GetTime and GetTime()) or time()
    if not force and RPAL._lastTRPBondSync and now-RPAL._lastTRPBondSync<10 then return 0 end
    RPAL._lastTRPBondSync=now;RPAL._syncingTRPBonds=true

    local profileAPI=TRP3_API.profile or {}
    local relationAPI=TRP3_API.register.relation or {}
    local registerAPI=TRP3_API.register or {}
    local currentProfile
    if type(profileAPI.getPlayerCurrentProfile)=="function" then
        local ok,value=pcall(profileAPI.getPlayerCurrentProfile)
        if ok and type(value)=="table" then currentProfile=value end
    end
    local relationMap,profileNotes={},{}
    if type(profileAPI.getData)=="function" then
        local ok,value=pcall(profileAPI.getData,"relation");if ok and type(value)=="table" then relationMap=value end
        ok,value=pcall(profileAPI.getData,"notes");if ok and type(value)=="table" then profileNotes=value end
    end
    if currentProfile then
        if next(relationMap)==nil and type(currentProfile.relation)=="table" then relationMap=currentProfile.relation end
        if next(profileNotes)==nil and type(currentProfile.notes)=="table" then profileNotes=currentProfile.notes end
    end

    local wanted={}
    for profileID in pairs(relationMap or {}) do wanted[tostring(profileID)]=true end
    for profileID,note in pairs(profileNotes or {}) do if CleanTRPName(note)~="" then wanted[tostring(profileID)]=true end end
    if type(TRP3_Notes)=="table" then
        for profileID,note in pairs(TRP3_Notes) do if CleanTRPName(note)~="" then wanted[tostring(profileID)]=true end end
    end

    local unitByProfile,seenProfiles={},{}
    if type(TRP3_Register)=="table" and type(TRP3_Register.character)=="table" then
        local playerName=ShortCharacterName(UnitName and UnitName("player") or ""):lower()
        for unitID,character in pairs(TRP3_Register.character) do
            local profileID=type(character)=="table" and character.profileID or nil
            if profileID and ShortCharacterName(unitID):lower()~=playerName then
                profileID=tostring(profileID)
                unitByProfile[profileID]=tostring(unitID);seenProfiles[profileID]=true
            end
        end
    end

    local imported=0
    for profileID in pairs(wanted) do
        if imported>=500 then break end
        local relationInfo
        if type(relationAPI.getRelation)=="function" then
            local ok,value=pcall(relationAPI.getRelation,profileID);if ok and type(value)=="table" then relationInfo=value end
        end
        local assignedRelation=relationMap and relationMap[profileID]
        local relationID=tostring((type(assignedRelation)=="string"or type(assignedRelation)=="number")and assignedRelation or(relationInfo and relationInfo.id)or"NONE")
        local note=CleanTRPName((profileNotes and profileNotes[profileID]) or (type(TRP3_Notes)=="table" and TRP3_Notes[profileID]) or "")
        if relationID~="NONE" or note~="" then
            local profile
            if type(registerAPI.getProfileOrNil)=="function" then
                local ok,value=pcall(registerAPI.getProfileOrNil,profileID);if ok then profile=value end
            elseif type(TRP3_Register)=="table" and type(TRP3_Register.profiles)=="table" then
                profile=TRP3_Register.profiles[profileID]
            end
            local name,trpFirstName,trpHasRoleplayName=TRPProfileName(profile,unitByProfile[profileID])
            if name~="" then
                local bond=RPAL.EnsureBond(name)
                if bond then
                    local relationText=CleanTRPName(relationInfo and relationInfo.name or "")
                    if relationText=="" and type(relationAPI.getRelationText)=="function" and relationID~="NONE" then
                        local ok,value=pcall(relationAPI.getRelationText,profileID,true);if ok then relationText=CleanTRPName(value) end
                    end
                    if relationText=="" then relationText=(relationID~="NONE" and relationID:gsub("_"," ") or "Known") end
                    if bond.relationshipSource~="RPAL" then bond.relationship=relationText;bond.relationshipSource="TRP" end
                    bond.name=name;bond.trpName=name;bond.trpProfileID=profileID;bond.trpUnitID=unitByProfile[profileID]
                    bond.trpFirstName=trpFirstName;bond.trpHasRoleplayName=trpHasRoleplayName and true or false
                    bond.trpRelationID=relationID;bond.trpRelationDefinitionID=tostring(relationInfo and relationInfo.id or"");bond.trpRelationName=relationText;bond.trpNote=note;bond.trpImported=true
                    bond.trpLastSeen=tonumber(profile and profile.time) or bond.trpLastSeen
                    imported=imported+1
                end
            end
        end
    end
    RPAL._syncingTRPBonds=false
    return imported
end

local function PopulateMomentFromScene(moment,scene,title,text)
    if not moment or not scene then return nil end
    moment.title=(title and title~="") and tostring(title) or tostring(scene.title or "A Remembered Session")
    moment.text=tostring(text~=nil and text or scene.note or "")
    moment.at=tonumber(scene.endedAt) or moment.at or time()
    moment.zone=tostring(scene.zone or "")
    moment.startedAt=tonumber(scene.startedAt) or 0
    moment.endedAt=tonumber(scene.endedAt) or 0
    moment.duration=tonumber(scene.duration) or ((tonumber(scene.endedAt) or 0) - (tonumber(scene.startedAt) or 0))
    moment.interactions=RPAL.SumMap(scene.interactions)
    moment.renownGained=math.floor(tonumber(scene.renownGained) or 0)
    moment.people=CopyArray(SortedParticipantNames(scene))
    moment.peopleDetails={}
    moment.locations=CopyLocations(EnsureSceneLocations(scene))
    moment.sceneID=scene.id
    if moment.pinned==nil then moment.pinned=false end

    for key,p in pairs(scene.participants or {}) do
        local name=RefreshParticipantIdentity(p,p.unitName or p.name)
        if name~="" then
            local trpName=tostring(p.trpName or "")
            moment.peopleDetails[#moment.peopleDetails+1]={
                key=key,name=name,unitName=tostring(p.unitName or ""),trpName=trpName,
                met=p.met==true,presence=p.presence==true,
                activeRP=IsActiveRPParticipant(p),activeSource=tostring(p.activeSource or ""),
                interactions=tonumber(p.interactions) or 0,
            }
        end
    end
    table.sort(moment.peopleDetails,function(a,b)
        if a.activeRP~=b.activeRP then return a.activeRP end
        if a.met~=b.met then return a.met end
        if a.interactions~=b.interactions then return a.interactions>b.interactions end
        return tostring(a.name):lower()<tostring(b.name):lower()
    end)
    return moment
end

function RPAL.SyncSavedSessionFromScene(scene,title,text)
    local d=GetRoleplayData()
    if not d or not scene or not scene.id then return nil,false end
    local wanted=tostring(scene.id)
    local primary=nil
    local duplicateIndexes={}
    for index,moment in ipairs(d.moments or {}) do
        if tostring(moment.sceneID or "")==wanted then
            if not primary then
                primary=moment
            else
                if moment.pinned then primary.pinned=true end
                duplicateIndexes[#duplicateIndexes+1]=index
            end
        end
    end
    if not primary then return nil,false end
    for i=#duplicateIndexes,1,-1 do table.remove(d.moments,duplicateIndexes[i]) end
    PopulateMomentFromScene(primary,scene,title,text)
    return primary,true
end

function RPAL.CreateMomentFromScene(scene,title,text)
    local d=GetRoleplayData()
    if not d or not scene then return nil end
    local existing=RPAL.SyncSavedSessionFromScene(scene,title,text)
    if existing then return existing end

    local moment={
        id=RPAL.NewID and RPAL.NewID("moment") or ("moment-"..tostring(time())),
        pinned=false,
    }
    PopulateMomentFromScene(moment,scene,title,text)
    table.insert(d.moments,1,moment)
    while #d.moments>MAX_MOMENTS do table.remove(d.moments) end
    return moment
end

function RPAL.CreateMoment(title,text)
    local d=GetRoleplayData()
    if not d then return nil end
    local moment={id=RPAL.NewID and RPAL.NewID("moment") or ("moment-"..tostring(time())),title=tostring(title or "A Remembered Session"),text=tostring(text or ""),at=time(),zone=PrettyZone(),people={},peopleDetails={},locations={CurrentLocation()},pinned=false}
    table.insert(d.moments,1,moment)
    while #d.moments>MAX_MOMENTS do table.remove(d.moments) end
    return moment
end

function RPAL.CollapseDuplicateSavedSessions()
    local d=GetRoleplayData()
    if not d then return 0 end
    local seen={}
    local duplicateIndexes={}
    for index,moment in ipairs(d.moments or {}) do
        local sceneID=tostring(moment.sceneID or "")
        if sceneID~="" then
            local primary=seen[sceneID]
            if primary then
                if moment.pinned then primary.pinned=true end
                duplicateIndexes[#duplicateIndexes+1]=index
            else
                seen[sceneID]=moment
            end
        end
    end
    for i=#duplicateIndexes,1,-1 do table.remove(d.moments,duplicateIndexes[i]) end
    return #duplicateIndexes
end

function RPAL.GetMoments()
    local d=GetRoleplayData()
    if d and RPAL.CollapseDuplicateSavedSessions then RPAL.CollapseDuplicateSavedSessions() end
    return d and d.moments or {}
end

function RPAL.SetMomentPinned(id,wanted)
    local d=GetRoleplayData()
    if not d then return false,"No data." end
    local target
    local pinned=0
    for _,m in ipairs(d.moments or {}) do
        if m.pinned then pinned=pinned+1 end
        if m.id==id then target=m end
    end
    if not target then return false,"Saved Session not found." end
    wanted=wanted and true or false
    if wanted and not target.pinned and pinned>=MAX_PINNED_MOMENTS then return false,"You can pin up to 3 Sessions." end
    target.pinned=wanted
    return true,target
end

function RPAL.DeleteMoment(id)
    local d=GetRoleplayData()
    if not d then return false end
    for i,m in ipairs(d.moments or {}) do
        if m.id==id then table.remove(d.moments,i);return true end
    end
    return false
end

function RPAL.GetPinnedMoments()
    local out={}
    for _,m in ipairs(RPAL.GetMoments()) do if m.pinned then out[#out+1]=m end end
    return out
end

function RPAL.CreateStoryArc(title,note)
    local d=GetRoleplayData()
    title=tostring(title or ""):gsub("^%s+",""):gsub("%s+$","")
    if not d or title=="" then return false,"Give the goal a name." end
    local arc={id=RPAL.NewID and RPAL.NewID("arc") or ("arc-"..tostring(time())),title=title,note=tostring(note or ""),progress=0,target=DEFAULT_ARC_STEPS,completed=false,createdAt=time()}
    table.insert(d.arcs,1,arc)
    return true,arc
end

function RPAL.GetStoryArcs()
    local d=GetRoleplayData()
    return d and d.arcs or {}
end

function RPAL.AdvanceStoryArc(id,amount)
    local d=GetRoleplayData()
    if not d then return false end
    for _,arc in ipairs(d.arcs or {}) do
        if arc.id==id then
            arc.progress=Clamp((tonumber(arc.progress) or 0)+(tonumber(amount) or 1),0,tonumber(arc.target) or DEFAULT_ARC_STEPS)
            if arc.progress>=arc.target then arc.completed=true;arc.completedAt=arc.completedAt or time() end
            return true,arc
        end
    end
    return false
end

function RPAL.ToggleStoryArcComplete(id)
    local d=GetRoleplayData()
    if not d then return false end
    for _,arc in ipairs(d.arcs or {}) do
        if arc.id==id then
            arc.completed=not arc.completed
            if arc.completed then arc.progress=arc.target or DEFAULT_ARC_STEPS;arc.completedAt=time() else arc.completedAt=nil end
            return true,arc
        end
    end
    return false
end

function RPAL.DeleteStoryArc(id)
    local d=GetRoleplayData()
    if not d then return false end
    for i,arc in ipairs(d.arcs or {}) do if arc.id==id then table.remove(d.arcs,i);return true end end
    return false
end

function RPAL.GetSceneSparkEnabled()
    local d=GetRoleplayData()
    return not d or not d.spark or d.spark.enabled~=false
end

function RPAL.SetSceneSparkEnabled(enabled)
    local d=GetRoleplayData()
    if not d then return end
    d.spark=d.spark or {}
    d.spark.enabled=enabled and true or false
    if enabled and RPAL.IsSceneActive() and RPAL.ScheduleNextSceneSpark then RPAL.ScheduleNextSceneSpark(true) end
end

function RPAL.GetRandomSceneSpark()
    if RPAL.GeneratePersonalityThought then
        local thought,row=RPAL.GeneratePersonalityThought("scene")
        if thought and thought~="" then return thought,row end
    end
    if #SPARKS==0 then return "" end
    local d=GetRoleplayData()
    local last=d and d.spark and tonumber(d.spark.lastIndex) or 0
    for _=1,#SPARKS do
        local idx=math.random(1,#SPARKS)
        if #SPARKS>1 and idx==last then idx=(idx % #SPARKS)+1 end
        local line=SPARKS[idx]
        local ok=not RPAL.PersonaThoughtFitsWorldContext or RPAL.PersonaThoughtFitsWorldContext(line,{source="legacy-scene"})
        if ok then
            if d then d.spark=d.spark or {};d.spark.lastIndex=idx end
            return line,{text=line,rawText=line,source="legacy-scene",at=time(),context="scene"}
        end
        last=idx
    end
    return ""
end


local function KnownBondFor(name)
    local d=GetRoleplayData()
    local key=PersonKey(name)
    if not d or key=="" then return nil end
    local bond=d.bonds and d.bonds[key]
    if bond and (tonumber(bond.scenesTogether) or 0)>0 then return bond end
    return nil
end

function RPAL.MaybeShowBondSpark(actor)
    if RPAL.IsSceneActive and RPAL.IsSceneActive() then return false end
    if not RPAL.GetSceneSparkEnabled or not RPAL.GetSceneSparkEnabled() then return false end
    local bond=KnownBondFor(actor)
    if not bond then return false end

    local d=GetRoleplayData()
    d.spark=d.spark or {enabled=true}
    local now=time()
    if now < (tonumber(d.spark.contextNextAt) or 0) then return false end

    -- Personality owns the social-thought frequency now.  Quiet characters
    -- surface these rarely; expressive characters are noticeably more chatty.
    if RPAL.IsPersonalitySocialThoughtEnabled and not RPAL.IsPersonalitySocialThoughtEnabled() then return false end
    local chance=RPAL.GetPersonalitySocialSparkChance and RPAL.GetPersonalitySocialSparkChance() or 0.28
    if math.random() > chance then return false end

    local thought,row
    if RPAL.GeneratePersonalityThought then thought,row=RPAL.GeneratePersonalityThought("social") else thought=RPAL.GetRandomSceneSpark() end
    if thought and thought~="" and RPAL.ShowSceneSpark then RPAL.ShowSceneSpark(thought,row) end
    d.spark.contextNextAt=now+math.random(45*60,75*60)
    d.spark.contextLastActor=PersonKey(actor)
    return true,thought
end

function RPAL.ScheduleNextSceneSpark(fresh)
    local now=(GetTime and GetTime()) or 0
    local wait=RPAL.GetPersonalitySparkDelay and RPAL.GetPersonalitySparkDelay(fresh) or math.random(fresh and 8*60 or 11*60,fresh and 14*60 or 18*60)
    RPAL._sceneSparkNextAt=now+wait
end

function RPAL.ForceSceneSpark()
    local text,row=RPAL.GetRandomSceneSpark()
    if text and text~=""and RPAL.ShowSceneSpark then RPAL.ShowSceneSpark(text,row) end
    if RPAL.IsSceneActive() then RPAL.ScheduleNextSceneSpark(false) end
    return text
end

function RPAL.StartSceneSparkTicker()
    if RPAL._sceneSparkTicker then return end
    if not C_Timer or not C_Timer.NewTicker then return end
    RPAL._sceneSparkTicker=C_Timer.NewTicker(20,function()
        local active=RPAL.IsSceneActive and RPAL.IsSceneActive()
        if active then
            UpdateActiveSceneClock()
            if RPAL.TrackSceneLocation then RPAL.TrackSceneLocation() end
        elseif RPAL.MaybeShowAmbientPersonalityThought then
            RPAL.MaybeShowAmbientPersonalityThought()
        end
        if not RPAL.char or not active or not RPAL.GetSceneSparkEnabled() then return end
        -- Persona Continuity already owns the single IC thought cadence.  Do
        -- not run the legacy Session Spark timer beside it; that old double
        -- path was how fixed fallback lines could bypass cognition/context.
        if RPAL.PersonaContinuityOwnsThoughtCadence then return end
        if RPAL.IsPersonalitySceneThoughtEnabled and not RPAL.IsPersonalitySceneThoughtEnabled() then return end
        local now=(GetTime and GetTime()) or 0
        if not RPAL._sceneSparkNextAt then RPAL.ScheduleNextSceneSpark(true) end
        if RPAL._sceneSparkNextAt and now>=RPAL._sceneSparkNextAt then RPAL.ForceSceneSpark() end
    end)
end

-- ------------------------------------------------------------
-- Integrate Chronicle tracking into existing RPAL progression with small,
-- reversible wrappers instead of changing the mature event lifecycle.
-- ------------------------------------------------------------

local PreviousAddRenown=RPAL.AddRenown
if type(PreviousAddRenown)=="function" then
    RPAL.AddRenown=function(amount,source,options)
        local gained=PreviousAddRenown(amount,source,options)
        if gained and gained>0 then RPAL.SceneRecordRenown(gained,source) end
        return gained
    end
end

local PreviousRecordStatistic=RPAL.RecordStatistic
if type(PreviousRecordStatistic)=="function" then
    RPAL.RecordStatistic=function(key,actor)
        local ok,reason=PreviousRecordStatistic(key,actor)
        if ok then
            RPAL.SceneRecordInteraction(key,actor)
            if RPAL.MaybeShowBondSpark then RPAL.MaybeShowBondSpark(actor) end
        end
        return ok,reason
    end
end

local PreviousRecordCharacterMet=RPAL.RecordCharacterMet
if type(PreviousRecordCharacterMet)=="function" then
    RPAL.RecordCharacterMet=function(guid,name,initiatedByPlayer)
        local ok,reason=PreviousRecordCharacterMet(guid,name,initiatedByPlayer)
        if RPAL.IsSceneActive() then RPAL.SceneRecordCharacterMet(name,ok and reason=="unique") end
        return ok,reason
    end
end

-- Participants should still appear in the scene even when a particular emote
-- cannot award Renown because of the normal score cooldown.
local PreviousHandleTextEmote=RPAL.HandleTextEmote
if type(PreviousHandleTextEmote)=="function" then
    RPAL.HandleTextEmote=function(message,sender,...)
        if RPAL.IsSceneActive() and type(message)=="string" then
            local own=(UnitName and UnitName("player")) or ""
            own=tostring(own):match("^([^%-]+)") or tostring(own)
            local who=tostring(sender or ""):match("^([^%-]+)") or tostring(sender or "")
            if who~="" and own~="" and who:lower()==own:lower() then
                if UnitExists and UnitExists("target") and UnitIsPlayer and UnitIsPlayer("target") and not UnitIsUnit("target","player") then
                    local target=(UnitName and UnitName("target")) or ""
                    if target~="" and tostring(message):lower():find(tostring(target):lower(),1,true) then MarkActiveRPParticipant(target,"targeted-emote") end
                end
            elseif who~="" then
                local lower=tostring(message):lower()
                local first=tostring((RPAL.char and RPAL.char.profile and RPAL.char.profile.firstName) or ""):lower()
                if lower:find("%f[%a]you%f[%A]") or (own~="" and lower:find(own:lower(),1,true)) or (#first>=3 and lower:find(first,1,true)) then
                    MarkActiveRPParticipant(who,"targeted-emote")
                end
            end
        end
        return PreviousHandleTextEmote(message,sender,...)
    end
end

local function SceneMessageMentions(message,name)
    local lower=tostring(message or ""):lower()
    name=tostring(name or ""):lower():gsub("|c%x%x%x%x%x%x%x%x",""):gsub("|r","")
    if #name>=3 and lower:find(name,1,true) then return true end
    local first=name:match("([%a\128-\255][%a\128-\255']+)")
    return first and #first>=3 and lower:find(first,1,true)~=nil
end

local function SceneUnitMatchesName(unit,name)
    if not UnitExists or not UnitExists(unit) then return false end
    if UnitIsPlayer and not UnitIsPlayer(unit) then return false end
    local unitName=ShortCharacterName(UnitName and UnitName(unit))
    return unitName~="" and unitName:lower()==ShortCharacterName(name):lower()
end

local function SceneTargetName()
    if not UnitExists or not UnitExists("target") then return nil end
    if UnitIsPlayer and not UnitIsPlayer("target") then return nil end
    if UnitIsUnit and UnitIsUnit("target","player") then return nil end
    local fullName
    if UnitFullName then
        local name,realm=UnitFullName("target")
        if name and name~="" then fullName=(realm and realm~="") and (name.."-"..realm) or name end
    end
    fullName=fullName or (UnitName and UnitName("target"))
    local name=ShortCharacterName(fullName)
    return name~="" and name or nil,fullName
end

-- TRP/LibMSP remote status can be cold for a few seconds after /reload. Do not
-- discard the first RP line/emote from an already-IC player while FC is still
-- arriving. Queue a small bounded resolver tied to the current Session.
local scenePendingStatus={}
local function QueueSceneICResolution(name,callback)
    if type(callback)~="function" or not C_Timer or not C_Timer.After then return false end
    local requestName=tostring(name or "")
    local shortName=ShortCharacterName(requestName)
    if shortName=="" then return false end
    local scene=RPAL.GetActiveScene and RPAL.GetActiveScene() or nil
    local sceneID=scene and scene.id
    if not sceneID then return false end
    local key=shortName:lower()
    local pending=scenePendingStatus[key]
    if not pending or pending.sceneID~=sceneID then
        pending={sceneID=sceneID,name=requestName,shortName=shortName,callbacks={},attempts=0,started=(GetTime and GetTime()) or 0}
        scenePendingStatus[key]=pending
    end
    if #pending.callbacks<8 then pending.callbacks[#pending.callbacks+1]=callback end
    if pending.running then return true end
    pending.running=true
    if RPAL.RequestRemoteTRPStatus then RPAL.RequestRemoteTRPStatus(name,false) end

    local function Retry()
        local current=scenePendingStatus[key]
        if current~=pending then return end
        local active=RPAL.GetActiveScene and RPAL.GetActiveScene() or nil
        if not active or active.id~=pending.sceneID then scenePendingStatus[key]=nil;return end
        pending.attempts=pending.attempts+1
        local status=RPAL.GetOtherTRPCharacterStatus and select(1,RPAL.GetOtherTRPCharacterStatus(pending.name)) or "UNKNOWN"
        if status=="IC" then
            scenePendingStatus[key]=nil
            for _,fn in ipairs(pending.callbacks) do pcall(fn) end
            return
        end
        if status~="UNKNOWN" and status~=nil then scenePendingStatus[key]=nil;return end
        local now=(GetTime and GetTime()) or pending.started
        if pending.attempts>=14 or now-pending.started>6 then scenePendingStatus[key]=nil;return end
        if RPAL.RequestRemoteTRPStatus and (pending.attempts==4 or pending.attempts==8) then
            RPAL.RequestRemoteTRPStatus(pending.name,true)
        end
        C_Timer.After(0.40,Retry)
    end
    C_Timer.After(0.25,Retry)
    return true
end

local sceneRemoteStatusCache={}
local SCENE_REMOTE_STATUS_TTL=1.25
local function SceneOtherICState(name)
    local key=PersonKey(ShortCharacterName(name))
    local runtimeNow=(GetTime and GetTime()) or 0
    local cached=sceneRemoteStatusCache[key]
    local status
    if cached and runtimeNow-(cached.at or 0)<SCENE_REMOTE_STATUS_TTL then
        status=cached.status
    else
        status=RPAL.GetOtherTRPCharacterStatus and select(1,RPAL.GetOtherTRPCharacterStatus(name)) or nil
        sceneRemoteStatusCache[key]={status=status or "UNKNOWN",at=runtimeNow}
    end
    if status=="IC" then return true,"IC" end
    if status=="UNKNOWN" or status==nil then
        if RPAL.RequestRemoteTRPStatus then RPAL.RequestRemoteTRPStatus(name,false) end
        return nil,"UNKNOWN"
    end
    return false,status
end

-- When the player is IC but has deliberately ended/paused recording, a real
-- direct RP interaction can offer the inverse Session warning. Remote TRP
-- status may still be cold immediately after /reload, so this resolver is
-- intentionally independent from an active Scene.
local idlePromptStatus={}
local function QueueIdlePromptICResolution(name,callback)
    if type(callback)~="function" or not C_Timer or not C_Timer.After then return false end
    local requestName=tostring(name or "")
    local shortName=ShortCharacterName(requestName)
    if shortName=="" then return false end
    local key=shortName:lower()
    local pending=idlePromptStatus[key]
    if not pending then
        pending={name=requestName,callbacks={},attempts=0,started=(GetTime and GetTime()) or 0}
        idlePromptStatus[key]=pending
    end
    if #pending.callbacks<4 then pending.callbacks[#pending.callbacks+1]=callback end
    if pending.running then return true end
    pending.running=true
    if RPAL.RequestRemoteTRPStatus then RPAL.RequestRemoteTRPStatus(requestName,false) end
    local function Retry()
        local current=idlePromptStatus[key]
        if current~=pending then return end
        if RPAL.IsSceneActive and RPAL.IsSceneActive() then idlePromptStatus[key]=nil;return end
        if RPAL.IsTRPInCharacter and not RPAL.IsTRPInCharacter() then idlePromptStatus[key]=nil;return end
        pending.attempts=pending.attempts+1
        local status=RPAL.GetOtherTRPCharacterStatus and select(1,RPAL.GetOtherTRPCharacterStatus(pending.name)) or "UNKNOWN"
        if status=="IC" then
            idlePromptStatus[key]=nil
            for _,fn in ipairs(pending.callbacks) do pcall(fn) end
            return
        end
        if status~="UNKNOWN" and status~=nil then idlePromptStatus[key]=nil;return end
        local now=(GetTime and GetTime()) or pending.started
        if pending.attempts>=10 or now-pending.started>4.5 then idlePromptStatus[key]=nil;return end
        if RPAL.RequestRemoteTRPStatus and pending.attempts==4 then RPAL.RequestRemoteTRPStatus(pending.name,true) end
        C_Timer.After(0.40,Retry)
    end
    C_Timer.After(0.20,Retry)
    return true
end

local function MaybeOfferSessionStartWithICPlayer(name,lookup,reason)
    if RPAL.IsSceneActive and RPAL.IsSceneActive() then return false end
    if not name or name=="" then return false end
    local remoteLookup=tostring(lookup or name)
    local remoteIC=SceneOtherICState(remoteLookup)
    if remoteIC==true then
        return RPAL.MaybePromptSessionStartForInteraction and RPAL.MaybePromptSessionStartForInteraction(name,reason)
    elseif remoteIC==nil then
        return QueueIdlePromptICResolution(remoteLookup,function()
            if RPAL.MaybePromptSessionStartForInteraction then RPAL.MaybePromptSessionStartForInteraction(name,reason) end
        end)
    end
    return false
end

-- Nearby IC speech remains broad presence context, but it no longer means that
-- every tavern speaker was actively roleplaying with the player.  Strong
-- signals (tracked interactions, direct/targeted emotes, addressing the player
-- or speaking with the selected target) opt a participant into activeRP.
RPAL._sceneParticipantEvent=CreateFrame("Frame")
for _,eventName in ipairs({"CHAT_MSG_SAY","CHAT_MSG_EMOTE","CHAT_MSG_YELL","CHAT_MSG_TEXT_EMOTE"}) do
    RPAL._sceneParticipantEvent:RegisterEvent(eventName)
end
RPAL._sceneParticipantEvent:SetScript("OnEvent",function(_,eventName,message,sender,...)
    local name=ShortCharacterName(sender)
    local own=ShortCharacterName(UnitName and UnitName("player"))
    if name=="" then return end
    if RPAL.IsTRPInCharacter and not RPAL.IsTRPInCharacter() then return end
    local isOwn=own~="" and name:lower()==own:lower()
    -- Lite Kit has no normal Token/customization economy. Qualifying IC writing
    -- earns a dedicated HS Token even before ODYN has opened a Session; this is
    -- intentionally independent from the Full Kit progression economy.
    if isOwn and RPAL.TryAwardLiteHSTokenFromRP then pcall(RPAL.TryAwardLiteHSTokenFromRP,message,eventName) end

    if not (RPAL.IsSceneActive and RPAL.IsSceneActive()) then
        if isOwn then
            -- Speaking/emoting while targeting another player is a conservative
            -- direct-RP signal. Yells stay room-wide and do not start prompts.
            local targetName,targetLookup=SceneTargetName()
            if targetName and eventName~="CHAT_MSG_YELL" then
                MaybeOfferSessionStartWithICPlayer(targetName,targetLookup or targetName,"Direct RP interaction detected.")
            end
        else
            local direct=false
            if eventName~="CHAT_MSG_YELL" then
                direct=SceneUnitMatchesName("target",name) or SceneUnitMatchesName("focus",name)
            end
            if not direct then
                direct=SceneMessageMentions(message,own)
                    or SceneMessageMentions(message,(RPAL.char and RPAL.char.profile and RPAL.char.profile.firstName) or "")
            end
            if not direct and (eventName=="CHAT_MSG_EMOTE" or eventName=="CHAT_MSG_TEXT_EMOTE") then
                direct=tostring(message or ""):lower():find("%f[%a]you%f[%A]")~=nil
            end
            if direct then
                MaybeOfferSessionStartWithICPlayer(name,tostring(sender or name),"Direct RP interaction detected.")
            elseif RPAL.MaybeShowBondSpark then
                RPAL.MaybeShowBondSpark(name)
            end
        end
        return
    end

    if isOwn then
        -- Speaking while deliberately targeting another IC player is a useful
        -- and conservative signal that this is an actual partner, not merely a
        -- person who happens to share the room. Yells stay room-wide unless a
        -- known participant is explicitly named.
        local targetName,targetLookup=SceneTargetName()
        if targetName and eventName~="CHAT_MSG_YELL" then
            local remoteIC=SceneOtherICState(targetLookup or targetName)
            if remoteIC==true then
                MarkActiveRPParticipant(targetName,"player-targeted-speech")
            elseif remoteIC==nil then
                QueueSceneICResolution(targetLookup or targetName,function()
                    MarkActiveRPParticipant(targetName,"player-targeted-speech-deferred")
                end)
            end
        end
        for _,p in pairs((RPAL.GetActiveScene() or {}).participants or {}) do
            local unitName=tostring(p.unitName or "")
            local displayName=RefreshParticipantIdentity(p,unitName~="" and unitName or p.name)
            if SceneMessageMentions(message,unitName) or SceneMessageMentions(message,p.trpName) or SceneMessageMentions(message,displayName) then
                MarkActiveRPParticipant(unitName~="" and unitName or displayName,"player-addressed")
            end
        end
        return
    end

    local function TrackIncomingParticipant()
        local p=TouchParticipant(name)
        if p then p.presence=true end

        local direct=SceneUnitMatchesName("target",name) or SceneUnitMatchesName("focus",name)
        if not direct then
            direct=SceneMessageMentions(message,own)
                or SceneMessageMentions(message,(RPAL.char and RPAL.char.profile and RPAL.char.profile.firstName) or "")
        end
        if not direct and (eventName=="CHAT_MSG_EMOTE" or eventName=="CHAT_MSG_TEXT_EMOTE") then
            direct=tostring(message or ""):lower():find("%f[%a]you%f[%A]")~=nil
        end
        if direct then
            MarkActiveRPParticipant(name,"direct-speech")
        elseif RPAL.MaybeShowBondSpark then
            RPAL.MaybeShowBondSpark(name)
        end
    end

    local remoteLookup=tostring(sender or name)
    local remoteIC=SceneOtherICState(remoteLookup)
    if remoteIC==true then
        TrackIncomingParticipant()
    elseif remoteIC==nil then
        QueueSceneICResolution(remoteLookup,TrackIncomingParticipant)
    end
end)
