local ADDON_NAME, RPAL = ...

-- ============================================================
-- RENOWN FEEL PASS
-- ============================================================
-- This module intentionally lives outside UI.lua.  The main UI chunk is close
-- to WoW's 200-local limit, while reward animation needs quite a bit of state.

local FONT=STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"
local WHITE="Interface\\Buttons\\WHITE8X8"
local SPARK="Interface\\CastingBar\\UI-CastingBar-Spark"
local GOLD={0.96,0.76,0.30}
local PALE={0.94,0.91,0.84}
local MUTED={0.62,0.61,0.58}
local CYAN={0.48,0.91,1.00}
local GREEN={0.42,0.95,0.62}

local function Scaled(n)
    return RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(n) or n
end

local function SetFont(fs,size,flags)
    if not fs then return end
    fs:SetFont(FONT,Scaled(size),flags or "OUTLINE")
    if RPAL.RegisterScalableFontObject then RPAL.RegisterScalableFontObject(fs,FONT,size,flags or "OUTLINE") end
end

local function Backdrop(frame,bgAlpha,border)
    frame:SetBackdrop({
        bgFile="Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",
        tile=false,tileSize=16,edgeSize=14,
        insets={left=4,right=4,top=4,bottom=4},
    })
    frame:SetBackdropColor(0.012,0.015,0.020,bgAlpha or .94)
    border=border or GOLD
    frame:SetBackdropBorderColor(border[1],border[2],border[3],.82)
end

local function CountMap(t)
    local n=0
    for _,v in pairs(type(t)=="table" and t or {}) do if v then n=n+1 end end
    return n
end

local function ProgressionSuppressed()
    return RPAL.DMKit and RPAL.DMKit.ShouldSuppressProgressionUI and RPAL.DMKit.ShouldSuppressProgressionUI()
end

local function GetWidget()
    local w=_G.RPAL_PlayerFrameWidget
    if w and w.IsShown and w:IsShown() then return w end
end

-- ------------------------------------------------------------
-- Player-frame Renown siphon
-- ------------------------------------------------------------
local widgetQueue={}
local widgetActive=nil
local widgetLabel=nil
local particleDriver=nil
local particles={}
local particlePool={}
local pulseDriver=nil
local pulseAge=99
local pulseBaseSize=26
local pulseRingSize=38
local pulsePeakSize=40
local pulsePeakRing=58
local widgetStreamSerial=0
local renownLoopStreamId=nil

local function FrameCenterOnUIParent(frame)
    if not frame or not frame.GetCenter then return nil,nil end
    local x,y=frame:GetCenter()
    if not x or not y then return nil,nil end
    -- GetCenter() is expressed in the frame's effective coordinate space.
    -- The flying +1 carriers are anchored to UIParent, so convert explicitly.
    -- This is the important bit when the default PlayerFrame / UI scale differs
    -- from UIParent: without it the particles veer left/down instead of landing
    -- on the movable ODYN orb.
    local frameScale=(frame.GetEffectiveScale and frame:GetEffectiveScale()) or 1
    local parentScale=(UIParent and UIParent.GetEffectiveScale and UIParent:GetEffectiveScale()) or 1
    if parentScale<=0 then parentScale=1 end
    local ratio=frameScale/parentScale
    return x*ratio,y*ratio
end

local function WidgetCenter()
    local w=GetWidget()
    if not w then return nil,nil,w end
    local x,y=FrameCenterOnUIParent(w)
    return x,y,w
end

local function EnsureWidgetLabel()
    if widgetLabel then return widgetLabel end
    local f=CreateFrame("Frame","RPAL_RenownWidgetGain",UIParent,"BackdropTemplate")
    f:SetSize(156,32);f:SetFrameStrata("TOOLTIP");f:SetFrameLevel(15100);f:EnableMouse(false);f:Hide()
    Backdrop(f,.90,GOLD)
    local glow=f:CreateTexture(nil,"BACKGROUND",nil,-1)
    glow:SetTexture(SPARK);glow:SetBlendMode("ADD");glow:SetPoint("TOPLEFT",5,-3);glow:SetPoint("BOTTOMRIGHT",-5,3);glow:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],.42)
    f.glow=glow
    local text=f:CreateFontString(nil,"OVERLAY");SetFont(text,13,"OUTLINE")
    text:SetPoint("CENTER",0,1);text:SetTextColor(GOLD[1],GOLD[2],GOLD[3],1);f.text=text
    widgetLabel=f
    return f
end

local function StartRenownEyeLoop(widget,streamId)
    if not widget or not streamId then return end
    if renownLoopStreamId==streamId then return end
    if renownLoopStreamId and RPAL.EndODYNEyeRenownLoop then
        RPAL.EndODYNEyeRenownLoop(widget)
    end
    renownLoopStreamId=streamId
    if RPAL.BeginODYNEyeRenownLoop then
        RPAL.BeginODYNEyeRenownLoop(widget,widget._odynEyeStyle)
    end
end

local function StopRenownEyeLoop(widget,streamId)
    if not streamId or renownLoopStreamId~=streamId then return end
    renownLoopStreamId=nil
    if widget and RPAL.EndODYNEyeRenownLoop then RPAL.EndODYNEyeRenownLoop(widget) end
end

local function ApplyRenownPulseScale(widget,scale)
    if not widget then return end
    widget._odynRenownPulseScale=scale or 1
    -- EyeFX owns layout while its signature is active; otherwise relayout the
    -- static artwork here.  This lets the thump and signature animation stack
    -- without two drivers fighting over ring/texture sizes.
    if not (RPAL.IsODYNEyeFXActive and RPAL.IsODYNEyeFXActive(widget)) then
        if RPAL.LayoutODYNEyeArt then RPAL.LayoutODYNEyeArt(widget) end
    end
end

local function PulseWidget(streamId)
    local w=GetWidget()
    if not w then return end
    -- Two-stage thump, but now entirely inside the Eye artwork.  The widget's
    -- anchored/clickable frame remains 26x26 so the pulse is perfectly centred.
    -- The Eye loop starts on the actual +1 impact, not when the Renown
    -- presentation/label begins. Subsequent impacts keep the same loop alive.
    StartRenownEyeLoop(w,streamId)
    pulseAge=0
    ApplyRenownPulseScale(w,pulsePeakSize/pulseBaseSize)
    if w.glow then w.glow:SetAlpha(1) end
    if not pulseDriver then
        pulseDriver=CreateFrame("Frame")
        pulseDriver:SetScript("OnUpdate",function(_,elapsed)
            pulseAge=pulseAge+(elapsed or 0)
            local widget=GetWidget()
            if not widget then return end
            local duration=.30
            local t=math.min(1,pulseAge/duration)
            local size
            if t<.52 then
                local q=t/.52
                local ease=1-((1-q)*(1-q))
                size=pulsePeakSize+((23-pulsePeakSize)*ease)
            else
                local q=(t-.52)/.48
                local ease=1-((1-q)*(1-q))
                size=23+((pulseBaseSize-23)*ease)
            end
            ApplyRenownPulseScale(widget,size/pulseBaseSize)
            if widget.ring then widget.ring:SetAlpha(.78+((1-t)*.22)) end
            if widget.glow then
                local hover=(widget.IsMouseOver and widget:IsMouseOver()) and 1 or 0
                widget.glow:SetAlpha(math.max(hover,(1-t)*1.00))
            end
            if t>=1 then
                ApplyRenownPulseScale(widget,1)
                if widget.ring then widget.ring:SetAlpha(1) end
                if widget.glow and not (widget.IsMouseOver and widget:IsMouseOver()) then widget.glow:SetAlpha(0) end
            end
        end)
    end
end

local function AcquireParticle()
    local f=table.remove(particlePool)
    if f then
        f:SetScale(1)
        if f.text then f.text:SetScale(1) end
        return f
    end
    f=CreateFrame("Frame",nil,UIParent)
    f:SetSize(34,18);f:SetFrameStrata("TOOLTIP");f:SetFrameLevel(15095);f:EnableMouse(false)
    local text=f:CreateFontString(nil,"OVERLAY");SetFont(text,9,"OUTLINE");text:SetPoint("CENTER");text:SetText("+1");text:SetTextColor(GOLD[1],GOLD[2],GOLD[3],1);f.text=text
    return f
end

local function EnsureParticleDriver()
    if particleDriver then return end
    particleDriver=CreateFrame("Frame")
    particleDriver:SetScript("OnUpdate",function(_,elapsed)
        local tx,ty=WidgetCenter()
        local stopStreams=nil
        for i=#particles,1,-1 do
            local p=particles[i]
            p.age=(p.age or 0)+(elapsed or 0)
            local t=math.min(1,p.age/.56)
            local ease=1-((1-t)*(1-t)*(1-t))
            local endx=tx or p.sx
            local endy=ty or p.sy
            local x=p.sx+((endx-p.sx)*ease)
            local y=p.sy+((endy-p.sy)*ease)+(math.sin(math.pi*t)*18)
            p:ClearAllPoints();p:SetPoint("CENTER",UIParent,"BOTTOMLEFT",x,y)
            p:SetAlpha(1-(t*.62))
            -- Never scale the positioned outer frame. WoW scales point offsets
            -- with the frame, which pulled the old +1 particles toward the
            -- screen origin instead of toward the movable ODYN orb. Scale only
            -- the glyph inside the fixed-position carrier.
            if p.text then p.text:SetScale(1-(t*.48)) end
            if t>=1 then
                local streamId=p.streamId
                local isLast=p.isLast
                p:Hide();if p.text then p.text:SetScale(1) end
                p.streamId=nil;p.isLast=nil
                table.remove(particles,i);particlePool[#particlePool+1]=p
                PulseWidget(streamId)
                -- Removal iterates newest-to-oldest. Defer the stop until all
                -- impacts for this frame are processed so a same-frame older
                -- +1 cannot accidentally restart a stream after its final hit.
                if isLast and streamId then
                    stopStreams=stopStreams or {}
                    stopStreams[streamId]=true
                end
            end
        end
        if stopStreams then
            local widget=GetWidget()
            for streamId in pairs(stopStreams) do StopRenownEyeLoop(widget,streamId) end
        end
    end)
end

local function SpawnOneParticle(sx,sy,streamId,isLast)
    if not sx or not sy then return end
    EnsureParticleDriver()
    local p=AcquireParticle();p.sx=sx+(math.random(-7,7));p.sy=sy+(math.random(-2,5));p.age=0
    p.streamId=streamId;p.isLast=isLast and true or false
    p:ClearAllPoints();p:SetPoint("CENTER",UIParent,"BOTTOMLEFT",p.sx,p.sy);p:SetAlpha(1);p:SetScale(1);p:Show()
    particles[#particles+1]=p
end

local function StartNextWidgetGain()
    if widgetActive or #widgetQueue==0 then return end
    local amount=table.remove(widgetQueue,1)
    local w=GetWidget()
    if not w then widgetQueue={};return end
    local f=EnsureWidgetLabel()
    widgetStreamSerial=widgetStreamSerial+1
    widgetActive={amount=amount,remaining=amount,age=0,acc=0,interval=math.max(.001,math.min(.040,2.45/math.max(1,amount))),visualStride=math.max(1,math.ceil(amount/650)),visualCounter=0,streamId=widgetStreamSerial}
    f.text:SetText("+"..tostring(amount).." RENOWN");f:SetAlpha(0);f:SetScale(.94);f:Show()
    f:SetScript("OnUpdate",function(self,elapsed)
        local a=widgetActive
        if not a then self:Hide();self:SetScript("OnUpdate",nil);return end
        a.age=a.age+(elapsed or 0)
        local x,y=WidgetCenter()
        if not x then
            if a.streamId then StopRenownEyeLoop(w,a.streamId) end
            widgetActive=nil;self:Hide();self:SetScript("OnUpdate",nil);return StartNextWidgetGain()
        end
        self:ClearAllPoints();self:SetPoint("CENTER",UIParent,"BOTTOMLEFT",x,y+104)
        local enter=math.min(1,a.age/.18);self:SetAlpha(enter);self:SetScale(.94+(enter*.06))
        self.glow:SetAlpha(.18+(((math.sin(a.age*8)+1)*.5)*.18))
        if a.age>=1.0 and a.remaining>0 then
            a.acc=a.acc+(elapsed or 0)
            local loops=0
            while a.acc>=a.interval and a.remaining>0 and loops<30 do
                a.acc=a.acc-a.interval;a.remaining=a.remaining-1;loops=loops+1;a.visualCounter=(a.visualCounter or 0)+1
                if RPAL.AdvanceRenownGainSound then RPAL.AdvanceRenownGainSound(1) end
                if (a.visualCounter%a.visualStride)==0 or a.remaining==0 then
                    SpawnOneParticle(x,y+103,a.streamId,a.remaining==0)
                end
            end
            self.text:SetText(a.remaining>0 and ("+"..tostring(a.remaining).." RENOWN") or "+0 RENOWN")
        end
        if a.remaining<=0 and a.age>=1.18 then
            local tail=math.max(0,1-((a.age-1.18)/.22));self:SetAlpha(tail)
            if tail<=0 then
                widgetActive=nil;self:Hide();self:SetScript("OnUpdate",nil)
                if C_Timer and C_Timer.After then C_Timer.After(.06,StartNextWidgetGain) else StartNextWidgetGain() end
            end
        end
    end)
end

function RPAL.QueuePlayerWidgetRenownFX(amount)
    amount=math.max(0,math.floor(tonumber(amount) or 0))
    if ProgressionSuppressed() then return false end
    if amount<=0 or not GetWidget() then return false end
    -- Rapid-fire gains (meeting several people at once, multi-proc rewards)
    -- accumulate into the still-visible pickup instead of building a long queue.
    if widgetActive and (tonumber(widgetActive.age) or 0)<.90 and (tonumber(widgetActive.remaining) or 0)>0 then
        widgetActive.amount=(tonumber(widgetActive.amount) or 0)+amount
        widgetActive.remaining=(tonumber(widgetActive.remaining) or 0)+amount
        widgetActive.interval=math.max(.001,math.min(.040,2.45/math.max(1,widgetActive.amount)))
        widgetActive.visualStride=math.max(1,math.ceil(widgetActive.amount/650))
        if widgetLabel and widgetLabel.text then widgetLabel.text:SetText("+"..tostring(widgetActive.remaining).." RENOWN") end
        return true
    end
    widgetQueue[#widgetQueue+1]=amount
    StartNextWidgetGain()
    return true
end

function RPAL.TestRenownEyeFX(amount)
    amount=math.max(1,math.floor(tonumber(amount) or 75))
    return RPAL.QueuePlayerWidgetRenownFX(amount)
end

-- ------------------------------------------------------------
-- Main ODYN progression bar tween + sparkle
-- ------------------------------------------------------------
local barDriver=nil
local barAnim=nil
local function EnsureBarSpark(bar)
    if not bar or bar._odynRenownSpark then return end
    local spark=bar:CreateTexture(nil,"OVERLAY")
    spark:SetTexture(SPARK);spark:SetBlendMode("ADD");spark:SetSize(30,34);spark:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],.95);spark:Hide()
    bar._odynRenownSpark=spark
end

local function SetBarVisual(value,maxv,label)
    local ui=RPAL.UI
    local bar=ui and ui.renownBar
    if not bar then return end
    maxv=math.max(1,tonumber(maxv) or 1);value=math.max(0,math.min(maxv,tonumber(value) or 0))
    bar:SetMinMaxValues(0,maxv);bar:SetValue(value)
    if ui.renownBarText then ui.renownBarText:SetText(label or (tostring(math.floor(value)).." / "..tostring(math.floor(maxv)))) end
    EnsureBarSpark(bar)
    if bar._odynRenownSpark then
        local width=math.max(1,bar:GetWidth() or 1);local frac=value/maxv
        bar._odynRenownSpark:ClearAllPoints();bar._odynRenownSpark:SetPoint("CENTER",bar,"LEFT",width*frac,0);bar._odynRenownSpark:Show()
    end
end

local function AnimateMainRenownBar(beforeLevel,beforeProgress,beforeNeeded,afterLevel,afterProgress,afterNeeded)
    local ui=RPAL.UI
    if not (ui and ui.renownBar and ui.renownBar:IsShown()) then return end
    barAnim={age=0,beforeLevel=beforeLevel,beforeProgress=beforeProgress,beforeNeeded=beforeNeeded,afterLevel=afterLevel,afterProgress=afterProgress,afterNeeded=afterNeeded}
    if not barDriver then
        barDriver=CreateFrame("Frame")
        barDriver:SetScript("OnUpdate",function(_,elapsed)
            local a=barAnim;if not a then return end
            a.age=a.age+(elapsed or 0)
            if a.afterLevel==a.beforeLevel then
                local t=math.min(1,a.age/.72);local ease=1-((1-t)*(1-t)*(1-t))
                local v=a.beforeProgress+((a.afterProgress-a.beforeProgress)*ease)
                SetBarVisual(v,a.afterNeeded)
                if t>=1 then barAnim=nil end
            else
                if a.age<.42 then
                    local t=a.age/.42;SetBarVisual(a.beforeProgress+((a.beforeNeeded-a.beforeProgress)*t),a.beforeNeeded)
                else
                    local t=math.min(1,(a.age-.42)/.56);local ease=1-((1-t)*(1-t))
                    SetBarVisual(a.afterProgress*ease,a.afterNeeded)
                    if t>=1 then barAnim=nil end
                end
            end
            if not barAnim then
                local bar=RPAL.UI and RPAL.UI.renownBar;if bar and bar._odynRenownSpark then bar._odynRenownSpark:Hide() end
                if RPAL.RefreshProgressionStrip then RPAL.RefreshProgressionStrip() end
            end
        end)
    end
end

-- ------------------------------------------------------------
-- One-hour Session reward presentation
-- ------------------------------------------------------------
local hourlyQueue={}
local hourlyActive=nil
local hourlyFrame=nil

-- Most hours use one of the two clean motivational lines.  The easter-eggs are
-- intentionally rare (~1% each), while the 0.3% rare find doubles the entire
-- hourly payout.
local HOURLY_COMMON_MESSAGES={
    "An hour of roleplay, keep it up!",
    "Keep it rolling!",
}
local HOURLY_RARE_MESSAGES={
    "We do be RPing.",
    "Hope you having a great time!",
    "I wish I had the same energy as you do man.",
    "What a legend.",
    "LF Renown Farm Group",
    "Up, Up, Down, Down, Left, Right, Left, Right, B, A, (Select), Start",
}
local HOURLY_RARE_FIND_MESSAGE="A suspicious little brass cog just rolled under your boot."

local function PickHourlyFlavor(force)
    if force=="rare" then
        return {text=HOURLY_RARE_FIND_MESSAGE,rare=true,multiplier=2,key="rare-find"}
    end
    local roll=math.random()*100
    if roll<.3 then return {text=HOURLY_RARE_FIND_MESSAGE,rare=true,multiplier=2,key="rare-find"} end
    roll=roll-.3
    for _,text in ipairs(HOURLY_RARE_MESSAGES) do
        if roll<1 then return {text=text,rare=false,multiplier=1,key="easteregg"} end
        roll=roll-1
    end
    return {text=HOURLY_COMMON_MESSAGES[math.random(1,#HOURLY_COMMON_MESSAGES)],rare=false,multiplier=1,key="common"}
end

local function EnsureHourlyFrame()
    if hourlyFrame then return hourlyFrame end
    local f=CreateFrame("Frame","RPAL_HourlyRenownToast",UIParent,"BackdropTemplate")
    f:SetSize(500,190);f:SetPoint("TOP",UIParent,"TOP",0,-155);f:SetFrameStrata("TOOLTIP");f:SetFrameLevel(15050);f:EnableMouse(false);f:Hide();Backdrop(f,.95,CYAN)
    local glow=f:CreateTexture(nil,"BACKGROUND",nil,-1);glow:SetTexture(SPARK);glow:SetBlendMode("ADD");glow:SetPoint("TOPLEFT",8,-6);glow:SetPoint("BOTTOMRIGHT",-8,6);glow:SetVertexColor(CYAN[1],CYAN[2],CYAN[3],.25);f.glow=glow
    local eyebrow=f:CreateFontString(nil,"OVERLAY");SetFont(eyebrow,8,"OUTLINE");eyebrow:SetPoint("TOP",0,-14);eyebrow:SetText("SESSION MILESTONE");eyebrow:SetTextColor(CYAN[1],CYAN[2],CYAN[3],1);f.eyebrow=eyebrow
    local title=f:CreateFontString(nil,"OVERLAY");SetFont(title,16,"OUTLINE");title:SetPoint("TOP",0,-31);title:SetWidth(452);title:SetHeight(42);title:SetWordWrap(true);title:SetJustifyH("CENTER");title:SetJustifyV("MIDDLE");title:SetText("An hour of roleplay, keep it up!");title:SetTextColor(PALE[1],PALE[2],PALE[3],1);f.title=title
    local track=CreateFrame("StatusBar",nil,f,"BackdropTemplate");track:SetSize(410,16);track:SetPoint("TOP",0,-82);track:SetStatusBarTexture(WHITE);track:SetMinMaxValues(0,1);track:SetValue(0);track:SetStatusBarColor(CYAN[1],CYAN[2],CYAN[3],.92);Backdrop(track,.80,CYAN);f.bar=track
    local spark=track:CreateTexture(nil,"OVERLAY");spark:SetTexture(SPARK);spark:SetBlendMode("ADD");spark:SetSize(28,30);spark:SetVertexColor(CYAN[1],CYAN[2],CYAN[3],1);spark:Hide();f.spark=spark
    local value=f:CreateFontString(nil,"OVERLAY");SetFont(value,22,"OUTLINE");value:SetPoint("TOP",0,-109);value:SetTextColor(GOLD[1],GOLD[2],GOLD[3],1);value:SetText("+0 RENOWN");f.value=value
    local bonus=f:CreateFontString(nil,"OVERLAY");SetFont(bonus,9,"OUTLINE");bonus:SetPoint("TOP",0,-140);bonus:SetWidth(450);bonus:SetTextColor(GREEN[1],GREEN[2],GREEN[3],1);f.bonus=bonus
    local extra=f:CreateFontString(nil,"OVERLAY");SetFont(extra,8,"OUTLINE");extra:SetPoint("TOP",0,-162);extra:SetWidth(452);extra:SetTextColor(MUTED[1],MUTED[2],MUTED[3],1);f.extra=extra
    hourlyFrame=f;return f
end

local function FinishHourly()
    local f=hourlyFrame;if f then f:Hide();f:SetScript("OnUpdate",nil);f:SetAlpha(1);f:SetScale(1) end
    hourlyActive=nil
    if #hourlyQueue>0 then
        local nextEntry=table.remove(hourlyQueue,1)
        if C_Timer and C_Timer.After then C_Timer.After(.22,function() RPAL.ShowHourlySessionReward(nextEntry.base,nextEntry.bonus,nextEntry.hour,nextEntry.preview,nextEntry.flavor) end)
        else RPAL.ShowHourlySessionReward(nextEntry.base,nextEntry.bonus,nextEntry.hour,nextEntry.preview,nextEntry.flavor) end
    end
end

function RPAL.ShowHourlySessionReward(base,bonus,hour,preview,forcedFlavor)
    base=math.max(1,math.floor(tonumber(base) or 50));bonus=math.max(0,math.floor(tonumber(bonus) or 0));hour=math.max(1,math.floor(tonumber(hour) or 1))
    local flavor=type(forcedFlavor)=="table" and forcedFlavor or PickHourlyFlavor(forcedFlavor)
    local multiplier=math.max(1,math.floor(tonumber(flavor.multiplier) or 1))
    -- Queue raw values so a rare 2x flavor is not multiplied again when this
    -- entry becomes active after another hourly presentation finishes.
    if hourlyActive then hourlyQueue[#hourlyQueue+1]={base=base,bonus=bonus,hour=hour,preview=preview,flavor=flavor};return end
    if multiplier>1 then base=base*multiplier;bonus=bonus*multiplier end
    if not preview and ProgressionSuppressed() then
        local gained=(RPAL.AddRenown and RPAL.AddRenown(base+bonus,"session_hourly",{hourlyReward=true})) or 0
        RPAL._deferredHourlyRenownFX=(tonumber(RPAL._deferredHourlyRenownFX) or 0)+math.max(0,gained)
        return
    end
    local f=EnsureHourlyFrame();hourlyActive={base=base,bonus=bonus,hour=hour,preview=preview and true or false,age=0,committed=false,gained=base+bonus,flavor=flavor}
    local accent=flavor.rare and GOLD or CYAN
    Backdrop(f,.95,accent)
    f.bar:SetStatusBarColor(accent[1],accent[2],accent[3],.92);f.spark:SetVertexColor(accent[1],accent[2],accent[3],1)
    f.eyebrow:SetText((flavor.rare and "SESSION MILESTONE  •  RARE FIND  •  HOUR " or "SESSION MILESTONE  •  HOUR ")..tostring(hour))
    f.eyebrow:SetTextColor(accent[1],accent[2],accent[3],1)
    local titleText=tostring(flavor.text or HOURLY_COMMON_MESSAGES[1])
    f.title:SetText(titleText)
    local titleSize=(#titleText>72 and 10.2) or (#titleText>48 and 11.8) or 16
    f.title:SetFont(FONT,Scaled(titleSize),"OUTLINE")
    f.title:SetWordWrap(true)
    f.title:SetTextColor(flavor.rare and GOLD[1] or PALE[1],flavor.rare and GOLD[2] or PALE[2],flavor.rare and GOLD[3] or PALE[3],1)
    f.bar:SetValue(0);f.spark:Hide();f.value:SetText("+0 RENOWN");f.value:SetAlpha(0);f.bonus:SetText("");f.bonus:SetTextColor(GREEN[1],GREEN[2],GREEN[3],1);f.bonus:SetAlpha(0);f.extra:SetText(preview and "Preview only — your Renown is unchanged." or "");f.extra:SetAlpha(preview and 1 or 0)
    f:SetAlpha(0);f:SetScale(.96);f:Show()
    f:SetScript("OnUpdate",function(self,elapsed)
        local a=hourlyActive;if not a then return end
        a.age=a.age+(elapsed or 0);local t=a.age
        local enter=math.min(1,t/.24);self:SetAlpha(enter);self:SetScale(.96+(enter*.04));self.glow:SetAlpha(.10+(((math.sin(t*5)+1)*.5)*.13))
        local fill=math.max(0,math.min(1,(t-.42)/.86));local ease=1-((1-fill)*(1-fill)*(1-fill));self.bar:SetValue(ease)
        if fill>0 then self.spark:Show();self.spark:ClearAllPoints();self.spark:SetPoint("CENTER",self.bar,"LEFT",(self.bar:GetWidth() or 410)*ease,0) end
        if fill>=1 and not a.committed then
            a.committed=true
            if a.preview then
                a.gained=a.base+a.bonus
                RPAL.QueuePlayerWidgetRenownFX(a.gained)
            else
                a.gained=(RPAL.AddRenown and RPAL.AddRenown(a.base+a.bonus,"session_hourly",{hourlyReward=true,levelUpDelay=2.45})) or (a.base+a.bonus)
                if RPAL.OnStatisticsChanged then pcall(RPAL.OnStatisticsChanged,"session_hourly",true,"hourly") end
            end
        end
        if t>=1.34 then
            self.value:SetAlpha(math.min(1,(t-1.34)/.15))
            local ct=math.max(0,math.min(1,(t-1.36)/.88));local shown=math.floor((a.gained*(1-((1-ct)*(1-ct)*(1-ct))))+.5)
            self.value:SetText("+"..tostring(shown).." RENOWN")
        end
        if a.bonus>0 and t>=2.08 then
            self.bonus:SetText("New faces this hour  •  +20% bonus ( +"..tostring(a.bonus).." )")
            self.bonus:SetTextColor(GREEN[1],GREEN[2],GREEN[3],1);self.bonus:SetAlpha(math.min(1,(t-2.08)/.24))
        elseif t>=2.08 then self.bonus:SetText("Hour complete  •  Renown secured");self.bonus:SetTextColor(CYAN[1],CYAN[2],CYAN[3],1);self.bonus:SetAlpha(math.min(1,(t-2.08)/.24)) end
        if a.flavor and a.flavor.rare and t>=2.34 then
            local boostExtra=math.max(0,(tonumber(a.gained) or 0)-((tonumber(a.base) or 0)+(tonumber(a.bonus) or 0)))
            local text="RARE FIND  •  0.3% roll  •  2x hourly Renown"
            if not a.preview and boostExtra>0 then text=text.."  •  Active boosts +"..tostring(boostExtra) end
            self.extra:SetText(text)
            self.extra:SetTextColor(GOLD[1],GOLD[2],GOLD[3],1);self.extra:SetAlpha(math.min(1,(t-2.34)/.20))
        elseif not a.preview and a.gained>(a.base+a.bonus) and t>=2.36 then
            self.extra:SetText("Active Renown boosts added +"..tostring(a.gained-(a.base+a.bonus)).." more.");self.extra:SetTextColor(MUTED[1],MUTED[2],MUTED[3],1);self.extra:SetAlpha(math.min(1,(t-2.36)/.22))
        end
        local tailStart=(a.flavor and a.flavor.rare) and 5.85 or 3.25
        local tailDuration=(a.flavor and a.flavor.rare) and .60 or .38
        if t>tailStart then
            local tail=math.max(0,1-((t-tailStart)/tailDuration));self:SetAlpha(tail)
            if tail<=0 then FinishHourly() end
        end
    end)
end

function RPAL.GetHourlySessionRenown()
    local level,progress,needed=1,0,10
    if RPAL.GetCurrentRenownLevel then level,progress,needed=RPAL.GetCurrentRenownLevel() end
    local reward=math.max(50,(tonumber(needed) or 10)*.075)
    reward=math.floor((reward+2.5)/5)*5
    return math.max(50,reward),level,needed
end

local hourlyTicker=nil
local function CheckHourlySessionReward()
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return end
    if not (RPAL.IsSceneActive and RPAL.IsSceneActive()) then return end
    local scene=RPAL.GetActiveScene and RPAL.GetActiveScene()
    if not scene then return end
    local duration=RPAL.GetSceneDuration and RPAL.GetSceneDuration(scene) or tonumber(scene.activeSeconds) or 0
    local completedHours=math.floor(math.max(0,duration)/3600)
    local awarded=math.max(0,math.floor(tonumber(scene.hourlyRenownAwards) or 0))
    while awarded<completedHours do
        awarded=awarded+1
        local currentNew=CountMap(scene.newCharacters)
        local lastNew=math.max(0,math.floor(tonumber(scene.hourlyRenownLastNewCount) or 0))
        local base=RPAL.GetHourlySessionRenown()
        local bonus=(currentNew>lastNew) and math.floor((base*.20)+.5) or 0
        scene.hourlyRenownAwards=awarded;scene.hourlyRenownLastNewCount=currentNew
        RPAL.ShowHourlySessionReward(base,bonus,awarded,false)
    end
end

if C_Timer and C_Timer.NewTicker then hourlyTicker=C_Timer.NewTicker(5.0,CheckHourlySessionReward) end


-- Flush the exact boundary before a Session leaves the active slot.  Without
-- this, ending at 60:00 between ticker beats could miss the first hourly award.
for _,name in ipairs({"EndScene","PauseAndStoreScene"}) do
    local original=RPAL[name]
    if type(original)=="function" then
        RPAL[name]=function(...)
            CheckHourlySessionReward()
            return original(...)
        end
    end
end

-- Respect DM Kit's existing "no progression interruptions" rule.  Hourly
-- Renown still accrues during the encounter; a compact summary lands after it.
local PreviousDMSessionEnded=RPAL.OnDMSessionEnded
RPAL.OnDMSessionEnded=function(...)
    if PreviousDMSessionEnded then PreviousDMSessionEnded(...) end
    local deferred=math.max(0,math.floor(tonumber(RPAL._deferredHourlyRenownFX) or 0));RPAL._deferredHourlyRenownFX=0
    if deferred>0 then
        if RPAL.ShowRewardToast then RPAL.ShowRewardToast("Session Renown Collected",deferred,"DM SESSION REWARDS") end
        RPAL.QueuePlayerWidgetRenownFX(deferred)
    end
end

-- ------------------------------------------------------------
-- Curve-v5 first-login presentation
-- ------------------------------------------------------------
local migrationFrame=nil
local MIGRATION_PRESENTATION_VERSION=2

local function MigrationNotice()
    local stats=RPAL.GetStatistics and RPAL.GetStatistics()
    local notice=stats and stats.renownCurveMigrationNotice
    if type(notice)~="table" or tonumber(notice.version)~=5 then return nil end
    return notice
end

function RPAL.IsRenownMigrationPresentationPending()
    local notice=MigrationNotice()
    return notice and (tonumber(notice.presentationVersion) or 0)<MIGRATION_PRESENTATION_VERSION or false
end

function RPAL.ShouldDelayAutomaticAchievementRewards()
    return RPAL.IsRenownMigrationPresentationPending() or RPAL._renownMigrationPresentationActive==true
end

local function FinishMigrationPresentation(notice,frame)
    RPAL._renownMigrationPresentationActive=false
    if type(notice)=="table" then
        notice.shown=true
        notice.shownAt=time and time() or 0
        notice.presentationVersion=MIGRATION_PRESENTATION_VERSION
    end
    if frame then frame:Hide();frame:SetScript("OnUpdate",nil);frame:SetAlpha(1);frame:SetScale(1) end
    -- Any achievement reconciliation that was held back while the player saw
    -- the curve change may now run.  Keep a small beat between presentations.
    if RPAL.ScheduleAutomaticAchievementCheck then
        if C_Timer and C_Timer.After then C_Timer.After(.55,function() RPAL.ScheduleAutomaticAchievementCheck(.05) end)
        else RPAL.ScheduleAutomaticAchievementCheck(.05) end
    end
end

local function ShowMigrationNotice()
    local notice=MigrationNotice()
    if not notice or (tonumber(notice.presentationVersion) or 0)>=MIGRATION_PRESENTATION_VERSION then
        RPAL._renownMigrationPresentationActive=false
        return
    end
    RPAL._renownMigrationPresentationActive=true
    local oldLevel=math.max(1,math.floor(tonumber(notice.oldLevel) or 1));local newLevel=math.max(1,math.floor(tonumber(notice.newLevel) or oldLevel));local gain=math.max(0,newLevel-oldLevel)
    local f=migrationFrame
    if not f then
        f=CreateFrame("Frame","RPAL_RenownCurveMigration",UIParent,"BackdropTemplate");f:SetSize(520,178);f:SetPoint("CENTER",0,70);f:SetFrameStrata("FULLSCREEN_DIALOG");f:SetFrameLevel(950);f:EnableMouse(false);Backdrop(f,.97,GOLD)
        local e=f:CreateFontString(nil,"OVERLAY");SetFont(e,8,"OUTLINE");e:SetPoint("TOP",0,-16);e:SetText("PROGRESSION RECALIBRATED");e:SetTextColor(CYAN[1],CYAN[2],CYAN[3],1);f.eyebrow=e
        local title=f:CreateFontString(nil,"OVERLAY");SetFont(title,18,"OUTLINE");title:SetPoint("TOP",0,-37);title:SetTextColor(PALE[1],PALE[2],PALE[3],1);f.title=title
        local level=f:CreateFontString(nil,"OVERLAY");SetFont(level,28,"OUTLINE");level:SetPoint("TOP",0,-66);level:SetTextColor(GOLD[1],GOLD[2],GOLD[3],1);f.level=level
        local bar=CreateFrame("StatusBar",nil,f,"BackdropTemplate");bar:SetSize(420,17);bar:SetPoint("TOP",0,-108);bar:SetStatusBarTexture(WHITE);bar:SetStatusBarColor(GOLD[1],GOLD[2],GOLD[3],.94);bar:SetMinMaxValues(0,1);Backdrop(bar,.82,GOLD);f.bar=bar
        local spark=bar:CreateTexture(nil,"OVERLAY");spark:SetTexture(SPARK);spark:SetBlendMode("ADD");spark:SetSize(30,31);spark:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],1);spark:Hide();f.spark=spark
        local detail=f:CreateFontString(nil,"OVERLAY");SetFont(detail,9,"OUTLINE");detail:SetPoint("TOP",0,-139);detail:SetWidth(460);detail:SetTextColor(MUTED[1],MUTED[2],MUTED[3],1);f.detail=detail
        migrationFrame=f
    end
    f.title:SetText(gain>0 and "Your earned Renown now carries you further." or "Renown requirements have been eased.")
    f.detail:SetText(gain>0 and ("Your Renown was preserved.  You gained "..tostring(gain).." Level"..(gain==1 and "" or "s").." from the new curve.") or "Your Renown was preserved and your progress toward the next Level increased.")
    f.level:SetText("LEVEL "..tostring(oldLevel));f.bar:SetValue(0);f.spark:Hide();f:SetAlpha(0);f:SetScale(.95);f._age=0;f:Show()
    f:SetScript("OnUpdate",function(self,elapsed)
        self._age=(self._age or 0)+(elapsed or 0);local t=self._age;local enter=math.min(1,t/.25);self:SetAlpha(enter);self:SetScale(.95+(enter*.05))
        local p=math.max(0,math.min(1,(t-.42)/1.55));local ease=1-((1-p)*(1-p)*(1-p));self.bar:SetValue(ease)
        if p>0 then self.spark:Show();self.spark:ClearAllPoints();self.spark:SetPoint("CENTER",self.bar,"LEFT",(self.bar:GetWidth() or 420)*ease,0) end
        local shown=oldLevel+math.floor(((newLevel-oldLevel)*ease)+.5);self.level:SetText("LEVEL "..tostring(shown)..(gain>0 and ("   |cff66e6ff+"..tostring(gain).."|r") or ""))
        if t>4.0 then local tail=math.max(0,1-((t-4.0)/.45));self:SetAlpha(tail);if tail<=0 then FinishMigrationPresentation(notice,self) end end
    end)
end

local loginFrame=CreateFrame("Frame")
loginFrame:RegisterEvent("PLAYER_LOGIN")
loginFrame:SetScript("OnEvent",function()
    -- Re-show the v5 recalibration once with the priority-safe presentation,
    -- including characters that technically saw the HF31 panel underneath a
    -- flood of reward/level-up notifications.
    if C_Timer and C_Timer.After then C_Timer.After(1.15,ShowMigrationNotice) else ShowMigrationNotice() end
end)

-- ------------------------------------------------------------
-- Final AddRenown wrapper: every real Renown gain gets tactile feedback.
-- ------------------------------------------------------------
local PreviousAddRenown=RPAL.AddRenown
if type(PreviousAddRenown)=="function" and not RPAL._renownFXWrappedAddRenown then
    RPAL._renownFXWrappedAddRenown=true
    RPAL.AddRenown=function(amount,source,options)
        local beforeLevel,beforeProgress,beforeNeeded=1,0,1
        if RPAL.GetCurrentRenownLevel then beforeLevel,beforeProgress,beforeNeeded=RPAL.GetCurrentRenownLevel() end
        local gained=PreviousAddRenown(amount,source,options)
        if gained and gained>0 then
            local afterLevel,afterProgress,afterNeeded=beforeLevel,beforeProgress,beforeNeeded
            if RPAL.GetCurrentRenownLevel then afterLevel,afterProgress,afterNeeded=RPAL.GetCurrentRenownLevel() end
            local opts=type(options)=="table" and options or {}
            if not opts.silentFX then
                RPAL.QueuePlayerWidgetRenownFX(gained)
                AnimateMainRenownBar(beforeLevel,beforeProgress,beforeNeeded,afterLevel,afterProgress,afterNeeded)
            end
        end
        return gained
    end
end
