local ADDON_NAME, RPAL = ...

-- ============================================================
-- ODYN Persona Context Layer v1
-- World position + coherent conversation gating + final thought filtering.
-- This layer is intentionally last in the Persona chain: every older generator
-- must pass the same spatial and conversational reality check before surfacing.
-- ============================================================

local VERSION=4
local MAX_LOCATIONS=24
local cache={at=-1000,value=nil}

local function trim(s)return tostring(s or""):gsub("^%s+",""):gsub("%s+$","")end
local function now()return time and time()or 0 end
local function gtime()return GetTime and GetTime()or 0 end
local function lower(s)return trim(s):lower():gsub("’","'"):gsub("%s+"," ")end
local function containsAny(s,list)for _,v in ipairs(list or{})do if s:find(v,1,true)then return v end end end
local function safeCall(fn,...)
    if type(fn)~="function"then return nil end
    local ok,a,b,c,d=pcall(fn,...);if ok then return a,b,c,d end
end

local CITY_MAPS={
    [84]="Stormwind",[85]="Orgrimmar",[87]="Ironforge",[89]="Darnassus",[103]="The Exodar",[110]="Silvermoon City",
    [111]="Shattrath City",[125]="Dalaran",[126]="Dalaran",[627]="Dalaran",[1161]="Boralus",[1165]="Dazar'alor",
    [2112]="Valdrakken",[2339]="Dornogal",
}
local CITY_NAMES={"stormwind","orgrimmar","ironforge","darnassus","exodar","silvermoon","shattrath","dalaran","boralus","dazar'alor","valdrakken","dornogal","city of threads"}

local function ensureStore()
    local p=RPAL.GetPersonalityData and RPAL.GetPersonalityData();if type(p)~="table"then return nil,nil end
    p.continuity=type(p.continuity)=="table"and p.continuity or{}
    p.continuity.world=type(p.continuity.world)=="table"and p.continuity.world or{}
    local w=p.continuity.world;w.version=VERSION;w.locations=type(w.locations)=="table"and w.locations or{}
    return w,p
end

local function worldSnapshot(force)
    if not force and cache.value and gtime()-cache.at<2 then return cache.value end
    local zone=trim(safeCall(GetZoneText)or"");local subZone=trim(safeCall(GetSubZoneText)or"")
    local mapID=C_Map and safeCall(C_Map.GetBestMapForUnit,"player")or nil
    local instanceName,instanceType=safeCall(GetInstanceInfo)
    local insideHouse=false;local housingArea=false
    if C_Housing then
        insideHouse=safeCall(C_Housing.IsInsideHouse)and true or false
        housingArea=safeCall(C_Housing.IsInsideHouseOrPlot)and true or insideHouse
    end
    local indoors=safeCall(IsIndoors)and true or false;local outdoors=safeCall(IsOutdoors)and true or false
    local zl=zone:lower();local sl=subZone:lower();local il=trim(instanceName):lower();local place=table.concat({zl,sl,il},"|")
    -- Housing sometimes reports map 0 or omits the C_Housing answer for a few
    -- frames while zoning.  The location labels and Founder's Point map are a
    -- conservative fallback, so a home thought cannot leak during that gap.
    if place:find("home interior",1,true)then insideHouse=true end
    if insideHouse or tonumber(mapID)==2352 or place:find("founder's point",1,true)or place:find("founders point",1,true)then housingArea=true end
    local cityName=CITY_MAPS[tonumber(mapID)]
    if not cityName then for _,name in ipairs(CITY_NAMES)do if place:find(name,1,true)then cityName=zone~=""and zone or name;break end end end
    local row={at=now(),zone=zone,subZone=subZone,mapID=tonumber(mapID),instanceName=trim(instanceName),instanceType=trim(instanceType),insideHouse=insideHouse,housingArea=housingArea,homeLike=housingArea,isCity=cityName~=nil,cityName=cityName or"",isStormwind=(tonumber(mapID)==84 or place:find("stormwind",1,true)~=nil),indoors=indoors,outdoors=outdoors}
    row.signature=table.concat({tostring(row.mapID or""),zone,subZone,insideHouse and"house"or"",housingArea and"housing"or"",indoors and"in"or(outdoors and"out"or"")},"|")
    cache.at=gtime();cache.value=row
    local store=ensureStore()
    if store then
        local previous=store.current
        if not previous or previous.signature~=row.signature then
            table.insert(store.locations,1,{at=row.at,zone=row.zone,subZone=row.subZone,mapID=row.mapID,insideHouse=row.insideHouse,housingArea=row.housingArea,isCity=row.isCity,cityName=row.cityName,indoors=row.indoors,outdoors=row.outdoors,signature=row.signature})
            while#store.locations>MAX_LOCATIONS do table.remove(store.locations)end
        end
        store.current=row
    end
    return row
end
RPAL.GetPersonaWorldContext=worldSnapshot

local UNIVERSAL_BAD={
    "i tend to feel positively about ","i would rather spend my attention somewhere other than ",
    "somebody i have been meaning to see again, though i cannot quite settle on who",
    "somebody i have been meaning to see again, though i can't quite settle on who",
    "have i met them before? something about them feels familiar",
    "there was something i meant to remember","did i forget something obvious?","i need to do something, but what is it?",
}
local VAGUE_MEMORY_BAD={
    "i still wonder what happened that day","wonder what happened that day","what happened that day","that day still",
    "something from recently keeps resurfacing","my mind keeps circling back to an old moment","a small memory keeps bobbing back",
    "something i thought was done keeps wandering through my head","my mind has picked an old detail",
    "bits of what happened earlier","what happened earlier keeps","what happened before","little pieces of earlier",
    "details from earlier keep","some of what happened recently","that recent stretch of time","certain parts of what happened",
    "keep remembering exactly what bothered me about that","what the memory says about what i wanted",
    "one small part of that memory","the memory is softer now","the detail that changed how i trust the rest of it",
    "what the moment asked of me","who needed help and who actually moved","that memory keeps returning",
    "sharp part of that memory","sharpest part of that memory","loose threads from old moments",
    "remembering the mood of a place more clearly than what happened","that little detail keeps coming back",
    "same expression for some reason","wonder why that bothered me","something about that still feels unfinished",
    "replaying one small part","mind picked that part","more affected by that than i realized",
    "a tiny part of that has stayed","coming back to the tone more than the words","did i misunderstand something earlier",
}
local HOME_AWAY={"did i kill the candles","candles before i left","when i get home","go home","head home","get back home","return home","left anything behind","left something behind","did i lock the door","locked everything up","put the fire out before leaving"}
local CITY_DESTINATION={"go to the city","head to the city","get back to the city","visit the city","go into the city","head into the city","go to town","head into town","get into town"}
local STORMWIND_DESTINATION={"go to stormwind","head to stormwind","get back to stormwind","return to stormwind","visit stormwind","make my way to stormwind","around stormwind again","being around stormwind again","miss stormwind","end up in stormwind again"}

local function eventLinked(row)
    local source=lower(row and row.source or"")
    if source:find("^cognitive%-")and source~="cognitive-state"then return true end
    if source=="cognitive-state"and type(row.why)=="table"and(tonumber(row.why.salience)or 0)>0 then return true end
    return false
end
local function episodicAnchor(row)
    local mind=type(row and row.mind)=="table"and row.mind or{}
    local episode=type(mind.episode)=="table"and mind.episode or{}
    return mind.memoryKind=="episode"and trim(episode.anchor)~=""
end
local function currentPersonLine(line,conversation)
    local name=lower(conversation and conversation.person or"");return name~=""and lower(line):find(name,1,true)~=nil
end
local function samePerson(a,b)
    a=lower(a):gsub("’","'"):gsub("[%s%-']+"," ");b=lower(b):gsub("’","'"):gsub("[%s%-']+"," ")
    return a~=""and b~=""and(a==b or a:match("^[^%s]+") == b:match("^[^%s]+"))
end
local function topicOverlap(cues,topics)
    if type(cues)~="table"or type(topics)~="table"then return false end
    local terms={}
    for key,value in pairs(topics)do
        local raw=type(key)=="number"and value or((value==true or value==1)and key or value)
        local term=lower(raw);if term~=""then terms[#terms+1]=term end
    end
    for _,left in ipairs(cues)do for _,right in ipairs(terms)do
        local a=lower(left);local b=lower(right)
        if a~=""and b~=""and(a==b or a:find(b,1,true)or b:find(a,1,true))then return true end
    end end
    return false
end
local function episodicMemoryFitsConversation(row,conversation)
    if not episodicAnchor(row)then return false end
    local mind=type(row and row.mind)=="table"and row.mind or{}
    local episode=type(mind.episode)=="table"and mind.episode or{}
    if trim(episode.person)~=""and trim(conversation and conversation.person)~=""and samePerson(episode.person,conversation.person)then return true end
    local cues=type(episode.cues)=="table"and episode.cues or{}
    return topicOverlap(cues.themes,conversation and conversation.topics)and(tonumber(mind.recallScore)or 0)>=57
end

function RPAL.PersonaThoughtFitsWorldContext(line,row,world,conversation)
    local s=lower(line);if s==""then return false,"empty"end
    if containsAny(s,UNIVERSAL_BAD)then return false,"unnatural-or-empty-reference"end
    if containsAny(s,VAGUE_MEMORY_BAD)and not(eventLinked(row)or episodicAnchor(row))then return false,"vague-memory-without-anchor"end
    world=type(world)=="table"and world or worldSnapshot()
    conversation=type(conversation)=="table"and conversation or(RPAL.GetPersonaConversationContext and RPAL.GetPersonaConversationContext()or{})
    if world.homeLike and containsAny(s,HOME_AWAY)then return false,"already-at-home"end
    if world.isCity and containsAny(s,CITY_DESTINATION)then return false,"already-in-city"end
    if world.isStormwind and containsAny(s,STORMWIND_DESTINATION)then return false,"already-in-stormwind"end
    if world.outdoors and containsAny(s,{"the room ","around the room","across the room","edge of the room","the ceiling","ordinary outdoor sounds instead","could use a bit of fresh air"})then return false,"outdoors-contradiction"end
    if world.indoors and containsAny(s,{"the road under my feet","the open sky above me","watching the horizon"})then return false,"indoors-outdoor-claim"end

    local source=lower(row and row.source or"")
    -- Character Memory is evidence-only. A name pasted into a generic sentence
    -- does not make it an episodic memory; the renderer must carry an Episode Card.
    if source=="character-memory"and not episodicAnchor(row)then return false,"memory-without-episode-card"end

    -- During a coherent exchange the other person's actual words get first
    -- refusal. Background boots, hobbies, old bonds and abstract motifs wait.
    if conversation.active and conversation.coherent then
        local allowed=eventLinked(row)or source=="survival"or source:find("injury",1,true)~=nil
        if allowed and eventLinked(row)and type(row.why)=="table"and trim(row.why.person)~=""and trim(conversation.person)~=""and not samePerson(row.why.person,conversation.person)and(tonumber(row.why.salience)or 0)<70 then allowed=false end
        if not allowed and source=="conversation-state"and currentPersonLine(line,conversation)then allowed=true end
        if not allowed and source=="relationship-dynamic"and currentPersonLine(line,conversation)then allowed=true end
        if not allowed and source=="character-memory"and episodicMemoryFitsConversation(row,conversation)then allowed=true end
        if not allowed then return false,"coherent-conversation-priority"end
    end

    if(source=="persona"or source=="persona-combo")and not conversation.coherent then
        if s:find("%f[%a]that%f[^%a]")or containsAny(s,{"what they said","the way they said","what happened earlier","earlier feels","before this","the answer","the story","the part","the rest of it"})then return false,"missing-live-referent"end
    end
    if not eventLinked(row)and not(source:find("memory",1,true)~=nil)and not conversation.coherent then
        if containsAny(s,{"what they said","the way they said","that look","that reaction","what happened earlier","bits of what happened earlier","them before"})then return false,"missing-live-referent"end
    end
    return true
end

local baseNatural=RPAL.PersonaThoughtPassesNaturalness
RPAL.PersonaThoughtPassesNaturalness=function(line,row)
    if baseNatural and not baseNatural(line,row)then return false end
    if containsAny(lower(line),UNIVERSAL_BAD)then return false end
    if containsAny(lower(line),VAGUE_MEMORY_BAD)and not(eventLinked(row)or episodicAnchor(row))then return false end
    return true
end

local HOUSE_LINES={
    "The quiet in here is starting to feel familiar.","I keep noticing the small sounds this house makes when everything settles.","It is easier to let my shoulders drop in here.","The air inside feels still enough for my thoughts to slow down.","This place is beginning to feel lived in rather than merely occupied.","I like knowing exactly where everything is in here.","The silence of the house feels comfortable at the moment.","For once, staying exactly where I am sounds fine."
}
local STORMWIND_LINES={
    "Stormwind never really goes quiet, even when I stop moving.","The city keeps carrying on around me whether I pay attention or not.","There is always another conversation half-heard in Stormwind.","Even standing still here, I can feel the city moving around me.","Stormwind has a way of making an ordinary pause feel busy.","The stone, the voices, the foot traffic - this city is difficult to mistake for anywhere else."
}
local CITY_LINES={
    "The city keeps moving around me even while I stand still.","There is enough noise here to keep silence from feeling empty.","I keep catching pieces of conversations that have nothing to do with me.","Crowds have their own rhythm when I stop trying to hurry through them.","The streets feel busy without needing anything from me.","There is always something else happening just beyond my attention here."
}
local function freshPick(pool,p)
    local seen={};for i=math.max(1,#(p.history or{})-80),#(p.history or{})do local r=p.history[i];if type(r)=="table"then seen[tostring(r.rawText or r.text or"")]=true end end
    local choices={};for _,line in ipairs(pool or{})do if not seen[line]then choices[#choices+1]=line end end
    local use=#choices>0 and choices or pool;return use and#use>0 and use[math.random(1,#use)]or nil
end
local function locationThought(world,p,context)
    local pool,source
    if world.insideHouse then pool,source=HOUSE_LINES,"world-house"
    elseif world.isStormwind then pool,source=STORMWIND_LINES,"world-stormwind"
    elseif world.isCity then pool,source=CITY_LINES,"world-city" end
    local raw,mindSource,mind
    if source and RPAL.GeneratePersonaWorldThought then raw,mindSource,mind=RPAL.GeneratePersonaWorldThought(world,p,context)end
    raw=raw or freshPick(pool,p);if not raw then return nil end
    source=mindSource or source
    local styled=RPAL.StylePersonaThought and RPAL.StylePersonaThought(raw)or raw
    local row={text=styled,rawText=raw,category="observant",source=source,at=now(),context=context,worldSignature=world.signature,mind=mind,why={trigger="The character noticed their present surroundings",evidence=world.subZone~=""and world.subZone or world.zone,appraisal="current physical location",state="",concern="",salience=0,confidence=1,relation="",intent="observation"}}
    p.history=type(p.history)=="table"and p.history or{};p.history[#p.history+1]=row;while#p.history>120 do table.remove(p.history,1)end
    return styled,row
end
local function removeGeneratedRow(p,row,line)
    local h=type(p and p.history)=="table"and p.history or{}
    for i=#h,math.max(1,#h-8),-1 do if h[i]==row or(type(h[i])=="table"and h[i].text==line and h[i].at==row.at)then table.remove(h,i);break end end
    local q=p and p.continuity and p.continuity.cognition
    if type(q)=="table"then
        if type(q.whyHistory)=="table"and q.whyHistory[1]and q.whyHistory[1].text==line then table.remove(q.whyHistory,1)end
        if type(q.decisions)=="table"and q.decisions[1]and tostring(q.decisions[1].source or"")==tostring(row and row.source or"")and now()-(tonumber(q.decisions[1].at)or 0)<=2 then table.remove(q.decisions,1)end
    end
end

local baseGenerate=RPAL.GeneratePersonalityThought
if baseGenerate then
    RPAL.GeneratePersonalityThought=function(context)
        local p=RPAL.GetPersonalityData and RPAL.GetPersonalityData();if type(p)~="table"then return baseGenerate(context)end
        local ctx=tostring(context or"ambient");local world=worldSnapshot();local conversation=RPAL.GetPersonaConversationContext and RPAL.GetPersonaConversationContext()or{}
        if ctx~="preview"and not ctx:find("reactive",1,true)and not(conversation.active and conversation.coherent)and math.random()<.11 then
            local line,row=locationThought(world,p,ctx);if line then return line,row end
        end
        local attempts=(conversation.active and conversation.coherent)and 1 or 14
        for _=1,attempts do
            local line,row=baseGenerate(context);if not line then return nil,row end
            row=type(row)=="table"and row or{text=line,rawText=line,source="persona",at=now(),context=ctx}
            local ok,reason=RPAL.PersonaThoughtFitsWorldContext(line,row,world,conversation)
            if ok then row.worldSignature=world.signature;row.conversationScore=conversation.score;return line,row end
            row.suppressedReason=reason;removeGeneratedRow(p,row,line)
        end
        return nil
    end
end
