local ADDON_NAME, RPAL = ...

-- ============================================================
-- ODYN Persona Calibration + learning notices
-- Calibration is the only manual Persona authoring surface. Once committed,
-- the live Persona becomes read-only and ODYN owns future movement.
-- ============================================================

local WHITE="Interface\\Buttons\\WHITE8X8"
local FONT=STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"
local C={bg={.010,.009,.022,.985},panel={.025,.020,.046,.985},panel2={.036,.028,.060,.985},border={.30,.23,.47,.95},gold={.90,.70,.34,1},pale={.91,.92,.95,1},muted={.56,.58,.66,1},teal={.33,.78,.76,1},green={.28,.82,.50,1},red={.93,.35,.40,1}}
local calibrationFrame,insightToast

local function clamp(v,lo,hi)v=tonumber(v)or lo;if v<lo then return lo elseif v>hi then return hi end;return v end
local function backdrop(f,bg,border)if not f or not f.SetBackdrop then return end;f:SetBackdrop({bgFile=WHITE,edgeFile=WHITE,edgeSize=1});f:SetBackdropColor(bg[1],bg[2],bg[3],bg[4]or 1);f:SetBackdropBorderColor(border[1],border[2],border[3],border[4]or 1)end
local function text(parent,value,size,color)
    local fs=parent:CreateFontString(nil,"OVERLAY");fs:SetFont(FONT,size or 10,"");fs:SetText(tostring(value or ""));color=color or C.pale;fs:SetTextColor(color[1],color[2],color[3],color[4]or 1)
    if RPAL.RegisterScalableFontObject then RPAL.RegisterScalableFontObject(fs,FONT,size or 10,"")end
    return fs
end
local function button(parent,label,w,h)
    local b=CreateFrame("Button",nil,parent,"BackdropTemplate");b:SetSize(w or 120,h or 28);b.label=text(b,label,10.2,C.pale);b.label:SetPoint("CENTER");backdrop(b,C.panel2,C.border)
    b:SetScript("OnEnter",function(self)if not self._locked then self:SetBackdropBorderColor(C.gold[1],C.gold[2],C.gold[3],1)end end)
    b:SetScript("OnLeave",function(self)if self._refreshVisual then self:_refreshVisual()else self:SetBackdropBorderColor(C.border[1],C.border[2],C.border[3],C.border[4])end end)
    return b
end

local CAL_STEPS={
    {kind="alignment",title="MORAL INSTINCT",question="When nobody is forcing a choice, where does this character usually pull?"},
    {key="chaoticLawful",title="CHAOTIC <-> LAWFUL",question="Do they improvise around rules, or feel safer when rules and procedure mean something?"},
    {key="forgivingVindictive",title="FORGIVING <-> VINDICTIVE",question="When somebody genuinely hurts them, do they let go or remember it?"},
    {key="altruisticSelfish",title="ALTRUISTIC <-> SELFISH",question="When helping someone costs them something, whose needs usually win?"},
    {key="truthfulDeceitful",title="TRUTHFUL <-> DECEITFUL",question="Are they naturally direct, or comfortable hiding and bending the truth?"},
    {key="gentleBrutal",title="GENTLE <-> BRUTAL",question="When pressure rises, do they reach for restraint or force?"},
    {key="superstitiousRational",title="SUPERSTITIOUS <-> RATIONAL",question="Do omens and instinct carry weight, or do they want evidence and explanation?"},
    {key="renegadeParagon",title="RENEGADE <-> PARAGON",question="Do they resist expectations, or try to embody a standard others can rely on?"},
    {key="cautiousImpulsive",title="CAUTIOUS <-> IMPULSIVE",question="Do they check the ground first, or move and solve the problem on the way?"},
    {key="asceticBonVivant",title="ASCETIC <-> BON VIVANT",question="Are they content with little, or drawn to comfort, food, drink and worldly pleasures?"},
    {key="valorousSpineless",title="VALOROUS <-> SPINELESS",question="When fear is real, do they usually stand or look for a way out?"},
    {key="introvertedExtroverted",title="INTROVERTED <-> EXTROVERTED",question="Does social energy come from retreating inward, or from actively engaging with people?"},
    {kind="voice",title="INNER VOICE REGISTER • STARTING SEED",question="How does this character usually phrase thoughts to themselves right now? Morality does not decide vocabulary. This starts the voice model and remains editable; sustained future IC speech can move the effective register."},
    {kind="profanity",title="PROFANITY • STARTING SEED",question="How naturally does profanity appear in this character's private inner speech right now? This starts the cadence model and remains editable; sustained future IC speech can move the effective level."},
}

local function clearChoiceButtons(f)
    f.choices=f.choices or {}
    for _,b in ipairs(f.choices)do b:Hide();b:SetScript("OnClick",nil)end
end

local function setTopicVisual(b,value)
    value=tonumber(value)or 0
    b:SetBackdropColor(C.panel2[1],C.panel2[2],C.panel2[3],.98)
    if value>.2 then
        b:SetBackdropBorderColor(C.green[1],C.green[2],C.green[3],1);b.state:SetText("LIKE");b.state:SetTextColor(C.green[1],C.green[2],C.green[3],1)
    elseif value<-.2 then
        b:SetBackdropBorderColor(C.red[1],C.red[2],C.red[3],1);b.state:SetText("DISLIKE");b.state:SetTextColor(C.red[1],C.red[2],C.red[3],1)
    else
        b:SetBackdropBorderColor(C.border[1],C.border[2],C.border[3],.9);b.state:SetText("NEUTRAL");b.state:SetTextColor(C.muted[1],C.muted[2],C.muted[3],1)
    end
end

local function buildFrame()
    if calibrationFrame then return calibrationFrame end
    local f=CreateFrame("Frame","ODYN_PersonaCalibration",UIParent,"BackdropTemplate");calibrationFrame=f;f:SetSize(820,610);f:SetPoint("CENTER");f:SetFrameStrata("FULLSCREEN_DIALOG");f:SetFrameLevel(7600);f:SetToplevel(true);f:SetClampedToScreen(true);f:EnableMouse(true);backdrop(f,C.bg,C.gold);f:Hide()
    if RPAL.ApplyModalBackdrop then pcall(RPAL.ApplyModalBackdrop,f)end
    f.title=text(f,"PERSONA BETA CALIBRATION",19,C.gold);f.title:SetPoint("TOPLEFT",24,-20)
    f.progress=text(f,"",9.2,C.muted);f.progress:SetPoint("TOPRIGHT",-24,-26)
    f.rule=f:CreateTexture(nil,"ARTWORK");f.rule:SetTexture(WHITE);f.rule:SetPoint("TOPLEFT",22,-54);f.rule:SetPoint("TOPRIGHT",-22,-54);f.rule:SetHeight(1);f.rule:SetVertexColor(C.border[1],C.border[2],C.border[3],.9)
    f.stepTitle=text(f,"",14,C.pale);f.stepTitle:SetPoint("TOP",0,-82)
    f.question=text(f,"",11.8,C.pale);f.question:SetPoint("TOPLEFT",64,-116);f.question:SetPoint("TOPRIGHT",-64,-116);f.question:SetHeight(60);f.question:SetWordWrap(true);f.question:SetJustifyH("CENTER");f.question:SetJustifyV("MIDDLE")
    f.hint=text(f,"Choose the closest starting answer. Traits, Voice and Profanity become read-only after calibration; ODYN then learns gradual changes only from this character's own IC roleplay.",9.6,C.muted);f.hint:SetPoint("TOPLEFT",66,-178);f.hint:SetPoint("TOPRIGHT",-66,-178);f.hint:SetWordWrap(true);f.hint:SetJustifyH("CENTER")
    f.choiceHost=CreateFrame("Frame",nil,f);f.choiceHost:SetPoint("TOPLEFT",104,-238);f.choiceHost:SetSize(612,54)
    f.back=button(f,"BACK",94,28);f.back:SetPoint("BOTTOMLEFT",24,20)
    f.cancel=button(f,"CANCEL",94,28);f.cancel:SetPoint("BOTTOMRIGHT",-24,20);f.cancel:SetScript("OnClick",function()f:Hide()end)
    f.note=text(f,"Persona traits analyze this character's future IC /say and /emote; Voice style uses their own /say and /yell. Party, whisper, guild and fully parenthetical OOC lines are ignored.",9.2,C.muted);f.note:SetPoint("BOTTOM",0,62);f.note:SetWidth(590);f.note:SetWordWrap(true);f.note:SetJustifyH("CENTER")

    f.assoc=CreateFrame("Frame",nil,f);f.assoc:SetPoint("TOPLEFT",22,-70);f.assoc:SetPoint("BOTTOMRIGHT",-22,58);f.assoc:Hide()
    f.assocTitle=text(f.assoc,"LIKES & DISLIKES",15,C.gold);f.assocTitle:SetPoint("TOPLEFT",8,-4)
    f.assocHint=text(f.assoc,"Pick only the things that matter. Click a topic to cycle NEUTRAL → LIKE → DISLIKE. Likes use a green border; dislikes use a red border. ODYN may slowly change these later if your RP consistently says otherwise.",9.5,C.muted);f.assocHint:SetPoint("TOPLEFT",8,-31);f.assocHint:SetWidth(720);f.assocHint:SetWordWrap(true)
    f.assocCounter=text(f.assoc,"",9.2,C.pale);f.assocCounter:SetPoint("TOPRIGHT",-8,-8)
    local scroll=CreateFrame("ScrollFrame",nil,f.assoc,"UIPanelScrollFrameTemplate");f.assocScroll=scroll;scroll:SetPoint("TOPLEFT",4,-79);scroll:SetPoint("BOTTOMRIGHT",-24,50);if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll)end
    local child=CreateFrame("Frame",nil,scroll);f.assocChild=child;child:SetWidth(724);scroll:SetScrollChild(child)
    f.assocDone=button(f.assoc,"LIVING HABITS  >",170,30);f.assocDone:SetPoint("BOTTOMRIGHT",-7,7)
    f.assocBack=button(f.assoc,"BACK",92,30);f.assocBack:SetPoint("BOTTOMLEFT",7,7)
    if UISpecialFrames then local found=false;for _,n in ipairs(UISpecialFrames)do if n=="ODYN_PersonaCalibration"then found=true break end end;if not found then table.insert(UISpecialFrames,"ODYN_PersonaCalibration")end end
    return f
end

local function showTraitStep()
    local f=buildFrame();f.assoc:Hide();f.stepTitle:Show();f.question:Show();f.hint:Show();f.choiceHost:Show();f.back:Show();f.cancel:Show();f.note:Show();f.progress:Show();clearChoiceButtons(f)
    local i=f.step or 1;local step=CAL_STEPS[i];if not step then return end
    f.progress:SetText(string.format("%02d / %02d",i,#CAL_STEPS+2));f.stepTitle:SetText(step.title);f.question:SetText(step.question);f.back:SetEnabled(i>1);f.back:SetAlpha(i>1 and 1 or .35)
    f.back:SetScript("OnClick",function()if f.step>1 then f.step=f.step-1;showTraitStep()end end)
    local labels,values
    if step.kind=="alignment" then labels={"GOOD","GOOD / NEUTRAL","NEUTRAL","NEUTRAL / EVIL","EVIL"};values={1,2,3,4,5}
    elseif step.kind=="voice" then
        labels={"STREET","CASUAL","REFINED","ARISTOCRATIC"};values={"street","casual","refined","aristocratic"}
    elseif step.kind=="profanity" then
        labels={"NONE","RARE","OCCASIONAL","FREQUENT","HEAVY"};values={"none","rare","casual","frequent","heavy"}
    else
        local pair=RPAL.PERSONALITY_TRAIT_PAIRS and RPAL.PERSONALITY_TRAIT_PAIRS[step.key]or{left="LEFT",right="RIGHT"}
        labels={string.upper(pair.left),"LEAN\n"..string.upper(pair.left),"BALANCED","LEAN\n"..string.upper(pair.right),string.upper(pair.right)};values={14,32,50,68,86}
    end
    local count=#labels
    local bw=116
    local gap=(count==4) and 32 or 7
    local total=(count*bw)+((count-1)*gap)
    local startX=math.floor((608-total)/2)
    for n=1,5 do
        local b=f.choices[n]or button(f,"",bw,48);f.choices[n]=b;b:SetParent(f.choiceHost);b:ClearAllPoints()
        if n<=count then
            b:SetPoint("LEFT",startX+(n-1)*(bw+gap),0);b:SetSize(bw,48);b.label:SetText(labels[n]);b.label:SetWidth(108);b.label:SetWordWrap(true);b._choiceValue=values[n];b:Show()
        else
            b:Hide();b._choiceValue=nil
        end
        b:SetScript("OnClick",function(self)
            if step.kind=="alignment" then
                local presets={{good=.72,neutral=.23,evil=.05},{good=.52,neutral=.43,evil=.05},{good=.18,neutral=.64,evil=.18},{good=.05,neutral=.43,evil=.52},{good=.05,neutral=.23,evil=.72}}
                f.answers.alignment=presets[self._choiceValue]
            elseif step.kind=="voice" then f.answers.voice=self._choiceValue
            elseif step.kind=="profanity" then f.answers.profanity=self._choiceValue
            else f.answers.traits[step.key]=self._choiceValue end
            if f.step>=#CAL_STEPS then f.step=#CAL_STEPS+1;RPAL._ShowPersonaAssociationCalibration()else f.step=f.step+1;showTraitStep()end
        end)
    end
end

local function buildAssociationRows(f)
    if f.assocRows then return end
    f.assocRows={}
    local used={}
    local groups=RPAL.PERSONALITY_ASSOCIATION_GROUPS or {}
    local defs=RPAL.PERSONALITY_ASSOCIATION_TOPICS or {}
    local cols=4;local bw,bh,gx,gy=170,34,8,8
    local y=6

    local function addHeader(name)
        local h=text(f.assocChild,string.upper(tostring(name or "OTHER")),9.0,C.gold);h:SetPoint("TOPLEFT",8,-y);h:SetWidth(690);h:SetJustifyH("LEFT")
        local rule=f.assocChild:CreateTexture(nil,"ARTWORK");rule:SetTexture(WHITE);rule:SetPoint("TOPLEFT",8,-y-17);rule:SetSize(690,1);rule:SetVertexColor(C.border[1],C.border[2],C.border[3],.55)
        y=y+28
    end
    local function addTopic(item,indexInRow)
        local col=indexInRow%cols
        local b=CreateFrame("Button",nil,f.assocChild,"BackdropTemplate");b:SetSize(bw,bh);b:SetPoint("TOPLEFT",6+col*(bw+gx),-y);backdrop(b,C.panel2,C.border);b.key=item.key
        b.name=text(b,item.display,9.4,C.pale);b.name:SetPoint("LEFT",8,4);b.name:SetWidth(112);b.name:SetJustifyH("LEFT")
        b.state=text(b,"NEUTRAL",8.0,C.muted);b.state:SetPoint("BOTTOMLEFT",8,4)
        function b:_refreshVisual()setTopicVisual(self,tonumber(f.answers.associations[self.key])or 0)end
        b:SetScript("OnClick",function(self)local cur=tonumber(f.answers.associations[self.key])or 0;local nv=cur>.2 and -.70 or(cur<-.2 and 0 or .70);f.answers.associations[self.key]=nv;self:_refreshVisual();RPAL._RefreshPersonaCalibrationCounter()end)
        b:SetScript("OnEnter",function(self)self:SetBackdropColor(.050,.040,.078,1)end);b:SetScript("OnLeave",function(self)self:SetBackdropColor(C.panel2[1],C.panel2[2],C.panel2[3],1);self:_refreshVisual()end)
        f.assocRows[#f.assocRows+1]=b
    end

    for _,group in ipairs(groups)do
        local items={}
        for _,key in ipairs(group.keys or{})do local def=defs[key];if def and not used[key]then items[#items+1]={key=key,display=def.display or key};used[key]=true end end
        if #items>0 then
            addHeader(group.name)
            for i,item in ipairs(items)do
                addTopic(item,(i-1)%cols)
                if i%cols==0 then y=y+bh+gy end
            end
            if #items%cols~=0 then y=y+bh+gy end
            y=y+8
        end
    end

    local leftovers={}
    for key,def in pairs(defs)do if not used[key]then leftovers[#leftovers+1]={key=key,display=def.display or key}end end
    table.sort(leftovers,function(a,b)return a.display<b.display end)
    if #leftovers>0 then
        addHeader("OTHER")
        for i,item in ipairs(leftovers)do addTopic(item,(i-1)%cols);if i%cols==0 then y=y+bh+gy end end
        if #leftovers%cols~=0 then y=y+bh+gy end
    end
    f.assocChild:SetHeight(math.max(330,y+12))
end

function RPAL._RefreshPersonaCalibrationCounter()
    local f=buildFrame();local likes,dislikes=0,0
    for _,v in pairs(f.answers and f.answers.associations or {})do if v>.2 then likes=likes+1 elseif v<-.2 then dislikes=dislikes+1 end end
    f.assocCounter:SetText(string.format("%d LIKES   •   %d DISLIKES",likes,dislikes))
end

function RPAL._ShowPersonaAssociationCalibration()
    local f=buildFrame();clearChoiceButtons(f);f.stepTitle:Hide();f.question:Hide();f.hint:Hide();f.choiceHost:Hide();f.back:Hide();f.cancel:Hide();f.note:Hide();f.progress:SetText(string.format("%02d / %02d",#CAL_STEPS+1,#CAL_STEPS+2));f.assoc:Show();buildAssociationRows(f)
    for _,b in ipairs(f.assocRows or {})do b:_refreshVisual()end;RPAL._RefreshPersonaCalibrationCounter()
    f.assocBack:SetScript("OnClick",function()f.step=#CAL_STEPS;showTraitStep()end)
    f.assocDone:SetScript("OnClick",function()
        if RPAL.ApplyPersonaLivingCalibration then RPAL.ApplyPersonaLivingCalibration(f.answers.voice,f.answers.profanity,nil,nil)end
        local function finishCalibration()
            if RPAL.CompletePersonalityCalibration then RPAL.CompletePersonalityCalibration(f.answers.traits,f.answers.alignment,f.answers.associations)end
            if RPAL.ShowPersonaInsightToast then RPAL.ShowPersonaInsightToast("PERSONA BETA CALIBRATED","The character model is live. ODYN can now think, remember, form living states and reflect while the character is IC.","CALIBRATION COMPLETE")end
            if RPAL.RefreshPersonalityUI then RPAL.RefreshPersonalityUI()end
            if RPAL.OpenPersonalityUI then C_Timer.After(.12,function()if RPAL.IsPersonalityCalibrated and RPAL.IsPersonalityCalibrated()then RPAL.OpenPersonalityUI()end end)end
        end
        f:Hide()
        if RPAL.OpenPersonaLivingConfig then
            RPAL.OpenPersonaLivingConfig(true,finishCalibration)
        else
            finishCalibration()
        end
    end)
end

function RPAL.OpenPersonalityCalibration()
    if RPAL.IsPersonaOptedIn and not RPAL.IsPersonaOptedIn() then
        return false
    end
    local settings=RPAL.GetSettings and RPAL.GetSettings()or nil
    if settings and settings.personaEnabled==false then settings.personaEnabled=true end
    if RPAL.IsPersonalityCalibrated and RPAL.IsPersonalityCalibrated() then
        if RPAL.ShowPersonaInsightToast then RPAL.ShowPersonaInsightToast("PERSONA BETA LOCKED","Calibration is complete. ODYN now owns this Persona Beta model's growth.","READ-ONLY")end
        return false
    end
    local f=buildFrame();local d=RPAL.GetPersonalityData and RPAL.GetPersonalityData()or{}
    local living=RPAL.GetPersonaLivingConfig and RPAL.GetPersonaLivingConfig()or{}
    f.answers={traits={},alignment={good=(d.alignment and d.alignment.good)or.34,neutral=(d.alignment and d.alignment.neutral)or.46,evil=(d.alignment and d.alignment.evil)or.20},associations={},voice=(living.voice=="natural"and"casual"or living.voice)or"casual",profanity=living.profanity or"none"}
    for key in pairs(RPAL.PERSONALITY_TRAIT_PAIRS or{})do f.answers.traits[key]=(d.traits and d.traits[key])or 50 end
    local l=RPAL.GetPersonalityLearningData and RPAL.GetPersonalityLearningData()or nil;if l and l.manualAssociations then for k,v in pairs(l.manualAssociations)do f.answers.associations[k]=v end end
    f.step=1;showTraitStep();f:Show();f:Raise();return true
end
function RPAL.ClosePersonalityCalibration()
    if calibrationFrame then calibrationFrame:Hide();return true end
    return false
end

local insightQueue={}
local insightActive=nil

local function insightAccent(row)
    if row and row.kind=="association" then return (tonumber(row.score) or 0)<0 and C.red or C.green end
    if row and row.kind=="relationship" then return (tonumber(row.score) or 0)<0 and C.red or C.teal end
    if row and row.kind=="alignment" then return C.gold end
    if row and row.kind=="trait" then return C.teal end
    return C.gold
end

local function buildInsightToast()
    if insightToast then return insightToast end
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");insightToast=f
    f:SetSize(660,196);f:SetPoint("TOP",0,-118);f:SetFrameStrata("TOOLTIP");f:SetFrameLevel(965);f:EnableMouse(false);backdrop(f,C.panel,C.border);f:Hide()
    if f.SetClipsChildren then f:SetClipsChildren(true) end

    -- The presentation is intentionally self-contained. Older builds moved a
    -- large white sheen texture beyond the toast bounds; on some clients that
    -- could leave a bright rectangle hanging beside the card. All animated
    -- accents in this version remain clipped inside the card/trait track.
    f.glow=f:CreateTexture(nil,"BACKGROUND",nil,-1);f.glow:SetTexture(WHITE);f.glow:SetPoint("TOPLEFT",2,-2);f.glow:SetPoint("BOTTOMRIGHT",-2,2);f.glow:SetBlendMode("ADD");f.glow:SetAlpha(.04)
    f.accentBar=f:CreateTexture(nil,"ARTWORK");f.accentBar:SetTexture(WHITE);f.accentBar:SetSize(6,148);f.accentBar:SetPoint("LEFT",12,0);f.accentBar:SetBlendMode("ADD")
    f.headerLine=f:CreateTexture(nil,"ARTWORK");f.headerLine:SetTexture(WHITE);f.headerLine:SetHeight(1);f.headerLine:SetPoint("TOPLEFT",30,-29);f.headerLine:SetWidth(1);f.headerLine:SetAlpha(.68)

    f.kicker=text(f,"PERSONA BETA EVOLUTION",7.4,C.muted);f.kicker:SetPoint("TOPLEFT",30,-13)
    f.confidence=text(f,"",7.6,C.muted);f.confidence:SetPoint("TOPRIGHT",-28,-13);f.confidence:SetJustifyH("RIGHT")
    f.title=text(f,"PERSONA BETA LEARNED",13.0,C.pale);f.title:SetPoint("TOPLEFT",30,-39);f.title:SetPoint("TOPRIGHT",-28,-39);f.title:SetJustifyH("LEFT")
    f.direction=text(f,"",11.4,C.gold);f.direction:SetPoint("TOPLEFT",30,-63);f.direction:SetPoint("TOPRIGHT",-28,-63);f.direction:SetJustifyH("LEFT");f.direction:Hide()
    f.body=text(f,"",8.4,C.muted);f.body:SetPoint("TOPLEFT",30,-86);f.body:SetPoint("TOPRIGHT",-28,-86);f.body:SetHeight(30);f.body:SetWordWrap(true);f.body:SetJustifyH("LEFT");f.body:SetJustifyV("TOP")
    f.meta=text(f,"",7.4,C.muted);f.meta:SetPoint("BOTTOMRIGHT",-28,12);f.meta:SetJustifyH("RIGHT")

    f.leftLabel=text(f,"",7.1,C.pale);f.leftLabel:SetPoint("BOTTOMLEFT",32,13);f.leftLabel:SetWidth(190);f.leftLabel:SetJustifyH("LEFT");f.leftLabel:Hide()
    f.rightLabel=text(f,"",7.1,C.pale);f.rightLabel:SetPoint("BOTTOMRIGHT",-32,13);f.rightLabel:SetWidth(190);f.rightLabel:SetJustifyH("RIGHT");f.rightLabel:Hide()

    local track=CreateFrame("Frame",nil,f,"BackdropTemplate");f.track=track;track:SetSize(596,14);track:SetPoint("BOTTOMLEFT",32,35);backdrop(track,{.012,.010,.025,.98},{.20,.17,.31,.95});track:Hide()
    if track.SetClipsChildren then track:SetClipsChildren(true) end
    f.shiftFill=track:CreateTexture(nil,"ARTWORK");f.shiftFill:SetTexture(WHITE);f.shiftFill:SetHeight(8);f.shiftFill:SetAlpha(.18);f.shiftFill:Hide()
    f.oldMarker=track:CreateTexture(nil,"OVERLAY");f.oldMarker:SetTexture(WHITE);f.oldMarker:SetSize(3,22);f.oldMarker:SetVertexColor(C.muted[1],C.muted[2],C.muted[3],.95);f.oldMarker:Hide()
    f.newHalo=track:CreateTexture(nil,"OVERLAY");f.newHalo:SetTexture("Interface\\CastingBar\\UI-CastingBar-Spark");f.newHalo:SetBlendMode("ADD");f.newHalo:SetSize(24,38);f.newHalo:SetAlpha(0);f.newHalo:Hide()
    f.newMarker=track:CreateTexture(nil,"OVERLAY");f.newMarker:SetTexture(WHITE);f.newMarker:SetSize(5,26);f.newMarker:SetBlendMode("ADD");f.newMarker:Hide()
    f.trackSweep=track:CreateTexture(nil,"OVERLAY");f.trackSweep:SetTexture("Interface\\CastingBar\\UI-CastingBar-Spark");f.trackSweep:SetBlendMode("ADD");f.trackSweep:SetSize(18,30);f.trackSweep:SetAlpha(0);f.trackSweep:Hide()

    f.oldTag=text(f,"",6.7,C.muted);f.oldTag:SetWidth(66);f.oldTag:SetJustifyH("CENTER");f.oldTag:Hide()
    f.newTag=text(f,"",7.0,C.pale);f.newTag:SetWidth(72);f.newTag:SetJustifyH("CENTER");f.newTag:Hide()
    f.delta=text(f,"",7.1,C.gold);f.delta:SetPoint("BOTTOM",0,13);f.delta:SetWidth(200);f.delta:SetJustifyH("CENTER");f.delta:Hide()

    if RPAL.ApplyToastLayout then pcall(RPAL.ApplyToastLayout,f)end
    return f
end

local function finishInsight()
    local f=insightToast
    if f then f:SetScript("OnUpdate",nil);f:Hide();f:SetAlpha(1);f:SetScale(1) end
    insightActive=nil
    if #insightQueue>0 then
        if C_Timer and C_Timer.After then C_Timer.After(.14,function()if not insightActive then RPAL._StartNextPersonaInsight()end end)
        else RPAL._StartNextPersonaInsight() end
    end
end

function RPAL._StartNextPersonaInsight()
    if insightActive or #insightQueue==0 then return end
    local row=table.remove(insightQueue,1);insightActive={row=row,age=0,arrived=false}
    local f=buildInsightToast();local accent=insightAccent(row)
    backdrop(f,C.panel,accent)
    f.glow:SetVertexColor(accent[1],accent[2],accent[3],1)
    f.accentBar:SetVertexColor(accent[1],accent[2],accent[3],1)
    f.headerLine:SetVertexColor(accent[1],accent[2],accent[3],1)
    f.title:SetTextColor(C.pale[1],C.pale[2],C.pale[3],1)
    f.direction:SetTextColor(accent[1],accent[2],accent[3],1)
    f.delta:SetTextColor(accent[1],accent[2],accent[3],1)

    f.kicker:SetText(row.kind and ("PERSONA BETA EVOLUTION  •  "..string.upper(tostring(row.kind))) or "PERSONA BETA EVOLUTION")
    f.title:SetText(tostring(row.title or "PERSONA BETA LEARNED"))
    f.direction:SetText("");f.direction:Hide()
    f.body:SetText(tostring(row.detail or row.body or ""))
    f.meta:Show();f.meta:SetText(tostring(row.meta or ""))
    f.confidence:SetText("")
    f.track:Hide();f.leftLabel:Hide();f.rightLabel:Hide();f.oldMarker:Hide();f.newMarker:Hide();f.newHalo:Hide();f.trackSweep:Hide();f.shiftFill:Hide();f.oldTag:Hide();f.newTag:Hide();f.delta:Hide()

    if row.kind=="trait" and tonumber(row.before) and tonumber(row.after) then
        local pair=RPAL.PERSONALITY_TRAIT_PAIRS and RPAL.PERSONALITY_TRAIT_PAIRS[row.key]
        local left=tostring((pair and pair.left) or row.left or "LEFT")
        local right=tostring((pair and pair.right) or row.right or "RIGHT")
        local before=clamp(tonumber(row.before) or 50,0,100)
        local after=clamp(tonumber(row.after) or 50,0,100)
        local toward=(after<before) and left or ((after>before) and right or "BALANCE")
        local delta=math.abs(after-before)

        -- The trait itself and the direction of travel are the primary read.
        -- Explanatory copy is deliberately quieter so the player can parse the
        -- actual Persona change at a glance.
        f.title:SetText(string.upper(left).."  <->  "..string.upper(right))
        f.direction:SetText(after==before and "RETURNED TOWARD BALANCE" or ("SHIFTED TOWARD  "..string.upper(toward)))
        f.direction:Show()
        f.body:SetText(tostring(row.detail or "Repeated roleplay evidence changed ODYN's observed read of this trait."))
        f.confidence:SetText("CONFIDENCE  "..tostring(math.floor(((tonumber(row.confidence) or 0)*100)+.5)).."%")
        f.meta:SetText("");f.meta:Hide()

        f.track:Show();f.leftLabel:Show();f.rightLabel:Show();f.oldMarker:Show();f.newMarker:Show();f.newHalo:Show();f.shiftFill:Show();f.oldTag:Show();f.newTag:Show();f.delta:Show()
        f.leftLabel:SetText(string.upper(left));f.rightLabel:SetText(string.upper(right))
        f.oldTag:SetText("OLD  "..tostring(math.floor(before+.5)))
        f.newTag:SetText("NOW  "..tostring(math.floor(after+.5)))
        f.delta:SetText(string.format("%d  →  %d   •   Δ %d",math.floor(before+.5),math.floor(after+.5),math.floor(delta+.5)))
        f.newMarker:SetVertexColor(accent[1],accent[2],accent[3],1)
        f.newHalo:SetVertexColor(accent[1],accent[2],accent[3],1)
        f.shiftFill:SetVertexColor(accent[1],accent[2],accent[3],1)
        f.trackSweep:SetVertexColor(accent[1],accent[2],accent[3],1)
    elseif row.kind=="association" then
        local state=string.upper(tostring(row.state or ((tonumber(row.score) or 0)>=0 and "LIKE" or "DISLIKE")))
        f.direction:SetText(state);f.direction:Show()
        f.confidence:SetText("CONFIDENCE  "..tostring(math.floor(((tonumber(row.confidence) or 0)*100)+.5)).."%")
        f.meta:SetText("LEARNED PREFERENCE")
    elseif row.kind=="relationship" then
        f.direction:SetText((tonumber(row.score) or 0)<0 and "IMPRESSION COOLED" or "IMPRESSION WARMED");f.direction:Show()
        f.confidence:SetText("CONFIDENCE  "..tostring(math.floor(((tonumber(row.confidence) or 0)*100)+.5)).."%")
        f.meta:SetText("PERSONAL IMPRESSION")
    elseif row.kind=="alignment" then
        local b=row.beforeAlignment or{};local a=row.afterAlignment or{}
        local toward=string.upper(tostring(row.toward or row.label or"MORAL CORE"))
        f.title:SetText("MORAL CORE")
        f.direction:SetText("SHIFTED TOWARD  "..toward);f.direction:Show()
        if b.good and a.good then
            f.body:SetText(string.format("GOOD  %d → %d     •     NEUTRAL  %d → %d     •     EVIL  %d → %d",math.floor((tonumber(b.good)or 0)*100+.5),math.floor((tonumber(a.good)or 0)*100+.5),math.floor((tonumber(b.neutral)or 0)*100+.5),math.floor((tonumber(a.neutral)or 0)*100+.5),math.floor((tonumber(b.evil)or 0)*100+.5),math.floor((tonumber(a.evil)or 0)*100+.5)))
        end
        f.confidence:SetText("CONFIDENCE  "..tostring(math.floor(((tonumber(row.confidence) or 0)*100)+.5)).."%")
        f.meta:SetText("VISIBLE TRIANGLE MOVEMENT")
    end

    f.headerLine:SetWidth(1)
    f:SetAlpha(0);f:SetScale(.965);f:Show();f:Raise()
    f:SetScript("OnUpdate",function(self,elapsed)
        local a=insightActive;if not a then return end
        a.age=a.age+(elapsed or 0);local t=a.age;local r=a.row

        local enter=math.min(1,t/.28)
        local ease=1-((1-enter)*(1-enter)*(1-enter))
        self:SetAlpha(enter)
        local scale=.965+(ease*.035)
        self.glow:SetAlpha(.035+(((math.sin(t*4.6)+1)*.5)*.055))
        self.accentBar:SetAlpha(.62+(((math.sin(t*6.1)+1)*.5)*.34))
        self.headerLine:SetWidth(math.max(1,596*ease))

        if r.kind=="trait" and self.track:IsShown() then
            local before=clamp(tonumber(r.before) or 50,0,100)
            local after=clamp(tonumber(r.after) or 50,0,100)
            local w=self.track:GetWidth() or 596
            local oldX=w*(before/100)
            local targetX=w*(after/100)
            local move=math.max(0,math.min(1,(t-.55)/1.35))
            move=1-((1-move)*(1-move)*(1-move))
            local nowX=oldX+((targetX-oldX)*move)

            -- OLD never moves. NOW is born at the old location and visibly
            -- travels over the bar to the new observed position.
            self.oldMarker:ClearAllPoints();self.oldMarker:SetPoint("CENTER",self.track,"LEFT",oldX,0)
            self.newMarker:ClearAllPoints();self.newMarker:SetPoint("CENTER",self.track,"LEFT",nowX,0)
            self.newHalo:ClearAllPoints();self.newHalo:SetPoint("CENTER",self.track,"LEFT",nowX,0)
            self.newHalo:SetAlpha(.18+(.34*((math.sin(t*9)+1)*.5)))

            local lo=math.min(oldX,nowX);local width=math.max(2,math.abs(nowX-oldX))
            self.shiftFill:ClearAllPoints();self.shiftFill:SetPoint("LEFT",self.track,"LEFT",lo,0);self.shiftFill:SetWidth(width)

            self.oldTag:ClearAllPoints();self.oldTag:SetPoint("BOTTOM",self.track,"TOPLEFT",oldX,5)
            self.newTag:ClearAllPoints();self.newTag:SetPoint("BOTTOM",self.track,"TOPLEFT",nowX,17)

            -- A small contained spark travels with the transition. It is hidden
            -- after arrival, so nothing can remain floating beside the toast.
            if move<1 then
                self.trackSweep:Show();self.trackSweep:SetAlpha(.28*(1-move)+.08)
                self.trackSweep:ClearAllPoints();self.trackSweep:SetPoint("CENTER",self.track,"LEFT",nowX,0)
            else
                self.trackSweep:Hide();self.trackSweep:SetAlpha(0)
                if not a.arrived then a.arrived=true;a.arrivedAt=t end
            end

            if a.arrivedAt then
                local hit=t-a.arrivedAt
                if hit<.62 then
                    local pulse=math.sin(math.min(1,hit/.62)*3.14159)
                    scale=scale+(.018*pulse)
                    self.newHalo:SetAlpha(math.max(self.newHalo:GetAlpha() or 0,.58*pulse))
                    self.glow:SetAlpha(.05+(.14*pulse))
                end
            end
        end

        self:SetScale(scale)

        -- Give the player enough time to actually inspect the change. The fade
        -- begins at ~10 seconds; queued Persona discoveries wait their turn.
        local hold=10.0
        if t>hold then
            self.trackSweep:Hide();self.trackSweep:SetAlpha(0)
            local tail=math.max(0,1-((t-hold)/.58));self:SetAlpha(tail)
            if tail<=0 then finishInsight()end
        end
    end)
end

local function queueInsight(row)
    insightQueue[#insightQueue+1]=row
    while #insightQueue>8 do table.remove(insightQueue,1)end
    if not insightActive then RPAL._StartNextPersonaInsight()end
end

function RPAL.ShowPersonaInsightToast(title,body,meta)
    if RPAL.IsPersonaSystemEnabled and not RPAL.IsPersonaSystemEnabled() then return end
    queueInsight({title=tostring(title or "PERSONA BETA LEARNED"),detail=tostring(body or ""),meta=tostring(meta or "")})
end

function RPAL.OnPersonalityLearningChange(row)
    local p=RPAL.GetPersonalityData and RPAL.GetPersonalityData();if not p or not row or (RPAL.IsPersonaSystemEnabled and not RPAL.IsPersonaSystemEnabled())then return end
    queueInsight(row)
    if RPAL.RefreshPersonalityUI and RPAL.IsPersonalityOpen and RPAL.IsPersonalityOpen()then RPAL.RefreshPersonalityUI()end
end
