local ADDON_NAME, RPAL = ...

-- ============================================================
-- ODYN Seasons
-- ============================================================
-- Regular seasons are designed as three-month archival periods with a fresh
-- reward/content drop each month. Open Beta 2026 is the short preseason that
-- precedes Fall 2026.

RPAL.SEASON_SYSTEM_VERSION = 5
RPAL.SEASON_QUEST_REVISION = 7
RPAL.SEASON_CADENCE = { months=3, monthlyDrops=true }
RPAL.SEASON_DAILY_REWARD_MULTIPLIER = 0.50
RPAL.SEASON_WEEKLY_REWARD_MULTIPLIER = 0.50

local function Stamp(year,month,day,hour)
    return time({year=year,month=month,day=day,hour=hour or 0,min=0,sec=0})
end

local OPEN_BETA={
    id="open-beta-2026",
    name="Open Beta 2026",
    kind="PRESEASON",
    subtitle="The first ODYN preseason. Every quest completed helped shape what came next.",
    startAt=Stamp(2026,8,18,0),
    endAt=Stamp(2026,8,30,0), -- archived when Fall 2026 begins
    maxLevel=10,
    thresholds={0,150,320,510,720,950,1200,1470,1760,2070},
    monthlyDrops={
        {id="open-beta-live",label="OPEN BETA 2026",month=8,year=2026,description="Founder-era preseason progression."},
    },
    rewards={
        [2]={kind="token",amount=40,name="40 Tokens"},
        [3]={kind="cosmetic",cosmeticKind="portrait",name="Open Beta Halo",label="Open Beta Halo"},
        [4]={kind="token",amount=60,name="60 Tokens"},
        [5]={kind="token",amount=80,name="80 Tokens"},
        [6]={kind="cosmetic",cosmeticKind="level",name="Open Beta Crest",label="Open Beta Crest"},
        [7]={kind="token",amount=100,name="100 Tokens"},
        [8]={kind="token",amount=120,name="120 Tokens"},
        [9]={kind="token",amount=150,name="150 Tokens"},
        [10]={kind="cosmetic",cosmeticKind="background",name="Open Beta Expanse",label="Open Beta Expanse"},
    },
}

-- FALL 2026 ---------------------------------------------------------------
-- The first full three-month Season. Month one intentionally ships with only
-- 20 authored rewards; the same track can append another 20 without changing
-- existing level ownership when the next monthly content drop arrives.
local FALL_2026={
    id="fall-2026",
    name="Fall 2026",
    kind="SEASON",
    subtitle="Field notes, marked pages, and heirloom bindings gathered beneath the turning leaves.",
    startAt=Stamp(2026,8,30,0),
    endAt=Stamp(2026,11,30,0), -- exclusive: Aug 30 through Nov 29
    maxLevel=20,
    -- Open Beta level steps were ~150–310 SXP. Fall deliberately asks for
    -- roughly three times that per level and continues the same gentle ramp.
    thresholds={
        0,450,960,1530,2160,2850,3600,4410,5280,6210,
        7200,8250,9360,10530,11760,13050,14400,15810,17280,18810,
    },
    monthlyDrops={
        {id="fall-2026-opening",label="MAPLEFALL",month=8,year=2026,description="Twenty opening rewards for the first Fall 2026 collection."},
    },
    rewards={
        [1]={kind="cosmetic",cosmeticKind="focus",name="Maplefall Corners",label="Maplefall Corners Focus Mark"},
        [2]={kind="cosmetic",cosmeticKind="level",name="Maplefall Seal",label="Maplefall Seal Badge"},
        [3]={kind="cosmetic",cosmeticKind="tooltip",name="Pagefold Note",label="Pagefold Note Tooltip"},
        [4]={kind="cosmetic",cosmeticKind="portrait",name="Maplefall Frame",label="Maplefall Frame Portrait Frame",bonusRewards={{kind="theme",name="Kaldorei"}}},
        [5]={kind="cosmetic",cosmeticKind="toast",name="Gilded Dispatch",label="Gilded Dispatch Toast Frame"},
        [6]={kind="packs",amount=5,name="5 Hearthstone Packs"},
        [7]={kind="cosmetic",cosmeticKind="focus",name="Folio Brackets",label="Folio Brackets Focus Mark"},
        [8]={kind="cosmetic",cosmeticKind="portrait",name="Moonlit Frame",label="Moonlit Frame Portrait Frame",bonusRewards={{kind="theme",name="Sunset"},{kind="background",name="Blue Hour"}}},
        [9]={kind="cosmetic",cosmeticKind="level",name="Northstar Seal",label="Northstar Seal Badge"},
        [10]={kind="cosmetic",cosmeticKind="ornament",name="Maplefall Binding",label="Maplefall Binding Ornament",bonusRewards={{kind="header",name="Maplefall Crest"}}},
        [11]={kind="cosmetic",cosmeticKind="level",name="Archivist Seal",label="Archivist Seal Badge"},
        [12]={kind="cosmetic",cosmeticKind="portrait",name="Ember Frame",label="Ember Frame Portrait Frame",bonusRewards={{kind="background",name="Stormwind Dawn"}}},
        [13]={kind="cosmetic",cosmeticKind="tooltip",name="Maplefall Pointer",label="Maplefall Pointer Tooltip"},
        [14]={kind="cosmetic",cosmeticKind="portrait",name="Raven Frame",label="Raven Frame Portrait Frame",bonusRewards={{kind="animation",name="Sit"}}},
        [15]={kind="cosmetic",cosmeticKind="level",name="Wayglass Seal",label="Wayglass Seal Badge"},
        [16]={kind="packs",amount=5,name="5 Hearthstone Packs"},
        [17]={kind="cosmetic",cosmeticKind="toast",name="Field Journal",label="Field Journal Toast Frame"},
        [18]={kind="cosmetic",cosmeticKind="portrait",name="Autumn Frame",label="Autumn Frame Portrait Frame",bonusRewards={{kind="animation",name="Point"}}},
        [19]={kind="cosmetic",cosmeticKind="level",name="Wolf Seal",label="Wolf Seal Badge"},
        [20]={kind="cosmetic",cosmeticKind="toast",name="Heirloom Chronicle",label="Heirloom Chronicle Toast Frame",bonusRewards={{kind="effect",name="Maplefall Sparks"}}},
    },
}

RPAL.SEASONS={OPEN_BETA,FALL_2026}
RPAL.SEASONS_BY_ID={[OPEN_BETA.id]=OPEN_BETA,[FALL_2026.id]=FALL_2026}

RPAL.SEASON_REWARD_LOOKUP={
    ["portrait:Open Beta Halo"]={source="season",season=OPEN_BETA.id,level=3,label="OPEN BETA 2026 • LEVEL 3"},
    ["level:Open Beta Crest"]={source="season",season=OPEN_BETA.id,level=6,label="OPEN BETA 2026 • LEVEL 6"},
    ["background:Open Beta Expanse"]={source="season",season=OPEN_BETA.id,level=10,label="OPEN BETA 2026 • LEVEL 10"},
}
local function IndexSeasonReward(level,reward)
    if not reward then return end
    if reward.kind=="theme" then
        RPAL.SEASON_REWARD_LOOKUP["theme:"..tostring(reward.name)]={source="season",season=FALL_2026.id,level=level,label="FALL 2026 • LEVEL "..tostring(level)}
    elseif reward.kind=="cosmetic" and reward.cosmeticKind and reward.name then
        RPAL.SEASON_REWARD_LOOKUP[tostring(reward.cosmeticKind)..":"..tostring(reward.name)]={source="season",season=FALL_2026.id,level=level,label="FALL 2026 • LEVEL "..tostring(level)}
    elseif reward.kind and reward.name and reward.kind~="token" and reward.kind~="packs" then
        RPAL.SEASON_REWARD_LOOKUP[tostring(reward.kind)..":"..tostring(reward.name)]={source="season",season=FALL_2026.id,level=level,label="FALL 2026 • LEVEL "..tostring(level)}
    end
end
for level,reward in pairs(FALL_2026.rewards) do
    IndexSeasonReward(level,reward)
    for _,bonus in ipairs(reward.bonusRewards or {}) do IndexSeasonReward(level,bonus) end
end
RPAL.FALL_2026=FALL_2026

local DAILY_POOL={
    {id="d-session",metric="session_count",target=1,xp=50,title="One More Chapter",description="Complete an RP Session."},
    {id="d-minutes-30",metric="rp_minutes",target=30,xp=50,title="Stay Awhile",description="Record 30 minutes of RP Sessions."},
    {id="d-minutes-45",metric="rp_minutes",target=45,xp=60,title="Lose Track of Time",description="Record 45 minutes of RP Sessions."},
    {id="d-new-face",metric="characters_met",target=1,xp=55,title="A New Face",description="Meet one new character while In Character."},
    {id="d-emote-out",metric="emotes_out",target=3,xp=50,title="Make Contact",description="Target other players with 3 social emotes."},
    {id="d-emote-in",metric="emotes_received",target=2,xp=50,title="Noticed",description="Receive 2 eligible targeted social emotes."},
    {id="d-renown",metric="renown_gain",target=10,xp=55,title="Earn Your Name",description="Gain 10 Renown."},
    {id="d-session-30",metric="session_30",target=1,xp=55,title="A Proper Scene",description="Complete a Session lasting at least 30 minutes."},
    {id="d-session-60",metric="session_60",target=1,xp=70,title="The Long Conversation",description="Complete a Session lasting at least 60 minutes."},
}

local WEEKLY_POOL={
    {id="w-sessions",metric="session_count",target=4,xp=155,title="Four Chapters",description="Complete four RP Sessions."},
    {id="w-minutes",metric="rp_minutes",target=180,xp=175,title="A Week Remembered",description="Record three hours of RP Sessions."},
    {id="w-met",metric="characters_met",target=3,xp=165,title="Widen the Circle",description="Meet three new characters while In Character."},
    {id="w-emote-out",metric="emotes_out",target=12,xp=155,title="Social Butterfly",description="Target other players with 12 social emotes."},
    {id="w-emote-in",metric="emotes_received",target=8,xp=155,title="Familiar Presence",description="Receive 8 eligible targeted social emotes."},
    {id="w-renown",metric="renown_gain",target=500,xp=180,title="A Name Remembered",description="Gain 500 Renown."},
    {id="w-days",metric="rp_days",target=3,xp=165,title="Keep Coming Back",description="Complete RP Sessions on three different days."},
    {id="w-session-60",metric="session_60",target=1,xp=160,title="A Night to Remember",description="Complete a Session lasting at least 60 minutes."},
    {id="w-session-90",metric="session_90",target=1,xp=185,title="Lost in the Story",description="Complete a Session lasting at least 90 minutes."},
}

-- Daily and Weekly progression payouts are intentionally halved for the new
-- economy. Keep the authored values above as readable baselines and apply the
-- scale once before quests are copied/exported. Revision 5 refreshes existing
-- active rolls so stale pre-patch rewards cannot remain claimable.
for _,quest in ipairs(DAILY_POOL) do
    quest.baseXP=quest.xp
    quest.xp=math.max(1,math.floor((quest.xp*RPAL.SEASON_DAILY_REWARD_MULTIPLIER)+0.5))
end
for _,quest in ipairs(WEEKLY_POOL) do
    quest.baseXP=quest.xp
    quest.xp=math.max(1,math.floor((quest.xp*RPAL.SEASON_WEEKLY_REWARD_MULTIPLIER)+0.5))
end

local QUEST_BY_ID={}
for _,quest in ipairs(DAILY_POOL) do QUEST_BY_ID[quest.id]=quest end
for _,quest in ipairs(WEEKLY_POOL) do QUEST_BY_ID[quest.id]=quest end
RPAL.SEASON_QUEST_BY_ID=QUEST_BY_ID

local function CopyQuest(def)
    return {
        id=def.id,metric=def.metric,target=def.target,xp=def.xp,
        title=def.title,description=def.description,
        progress=0,complete=false,awarded=false,seenDays={},
    }
end

local function Hash(text)
    local value=2166136261
    text=tostring(text or "")
    for i=1,#text do
        value=(value*16777619 + string.byte(text,i)*97 + i*31)%2147483647
    end
    return value
end

local function Pick(pool,count,key,exclude)
    local out,used={},{}
    for id in pairs(exclude or {}) do used[id]=true end
    local total=#pool
    if total==0 then return out end
    local start=(Hash(key)%total)+1
    local stride=((Hash(key..":stride")%(math.max(1,total-1)))+1)
    if stride%2==0 then stride=stride+1 end
    local cursor=start
    local guard=0
    while #out<count and guard<(total*4) do
        local def=pool[cursor]
        if def and not used[def.id] then
            used[def.id]=true
            out[#out+1]=CopyQuest(def)
        end
        cursor=((cursor-1+stride)%total)+1
        guard=guard+1
    end
    -- Deterministic fallback when the stride happens to revisit a subset.
    if #out<count then
        for _,def in ipairs(pool) do
            if #out>=count then break end
            if not used[def.id] then used[def.id]=true;out[#out+1]=CopyQuest(def) end
        end
    end
    return out
end

local function DailyKey(now)
    return date("%Y-%m-%d",now or time())
end

local function WeeklyKey(now)
    return date("%Y-W%W",now or time())
end

local function EnsureRoot()
    if type(RPAL_DB)~="table" then return nil end
    RPAL_DB.seasons=type(RPAL_DB.seasons)=="table" and RPAL_DB.seasons or {}
    RPAL_DB.seasons.version=RPAL.SEASON_SYSTEM_VERSION
    RPAL_DB.seasons.entries=type(RPAL_DB.seasons.entries)=="table" and RPAL_DB.seasons.entries or {}
    return RPAL_DB.seasons
end

local function EnsureSeasonState(season)
    local root=EnsureRoot()
    if not root or not season then return nil end
    local state=root.entries[season.id]
    if type(state)~="table" then
        state={xp=0,claimedLevels={},createdAt=time()}
        root.entries[season.id]=state
    end
    state.xp=math.max(0,math.floor(tonumber(state.xp) or 0))
    state.claimedLevels=type(state.claimedLevels)=="table" and state.claimedLevels or {}
    if season.id=="fall-2026" and tonumber(state.rewardTrackVersion)~=4 then
        -- Fall's opening collection was refreshed before launch. Preserve SXP
        -- while allowing replaced cosmetic levels to reconcile once. Pack
        -- levels stay at 6/16; credit any packs already granted by older drafts
        -- so migration can never duplicate them.
        local previousVersion=tonumber(state.rewardTrackVersion) or 0
        state.legacyPackCredits={}
        if state.claimedLevels[6] then state.legacyPackCredits[6]=(previousVersion>=2) and 5 or 1 end
        if state.claimedLevels[16] then state.legacyPackCredits[16]=(previousVersion>=2) and 5 or 1 end
        state.claimedLevels={}
        state.rewardTrackVersion=4
    end
    state.daily=type(state.daily)=="table" and state.daily or {}
    state.weekly=type(state.weekly)=="table" and state.weekly or {}
    state.stats=type(state.stats)=="table" and state.stats or {}
    local stats=state.stats
    stats.sessions=math.max(0,math.floor(tonumber(stats.sessions) or 0))
    stats.rpMinutes=math.max(0,math.floor(tonumber(stats.rpMinutes) or 0))
    stats.charactersMet=math.max(0,math.floor(tonumber(stats.charactersMet) or 0))
    stats.emotesOut=math.max(0,math.floor(tonumber(stats.emotesOut) or 0))
    stats.emotesReceived=math.max(0,math.floor(tonumber(stats.emotesReceived) or 0))
    stats.renownGained=math.max(0,math.floor(tonumber(stats.renownGained) or 0))
    stats.longestSession=math.max(0,math.floor(tonumber(stats.longestSession) or 0))
    stats.days=type(stats.days)=="table" and stats.days or {}
    return state
end

function RPAL.GetSeasonByID(id)
    return RPAL.SEASONS_BY_ID and RPAL.SEASONS_BY_ID[tostring(id or "")] or nil
end

function RPAL.GetActiveSeason(now)
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return nil end
    now=tonumber(now) or time()
    for _,season in ipairs(RPAL.SEASONS or {}) do
        if now>=season.startAt and now<season.endAt then return season end
    end
    return nil
end

function RPAL.GetDisplayedSeason(now)
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return nil end
    now=tonumber(now) or time()
    local active=RPAL.GetActiveSeason(now)
    -- During the final launch week of a preseason, preview the incoming proper
    -- season in the Seasons UI while gameplay rewards continue using GetActiveSeason().
    if active and tostring(active.kind or "")=="PRESEASON" then
        local incoming=nil
        for _,season in ipairs(RPAL.SEASONS or {}) do
            if season.startAt>now and season.startAt-now<=7*86400 and tostring(season.kind or "")~="PRESEASON" and (not incoming or season.startAt<incoming.startAt) then incoming=season end
        end
        if incoming then return incoming end
    end
    if active then return active end
    -- Surface the next season during its launch week so the panel can be
    -- previewed before the timer reaches zero instead of falling back to an
    -- archived preseason.
    local upcoming=nil
    for _,season in ipairs(RPAL.SEASONS or {}) do
        if season.startAt>now and season.startAt-now<=7*86400 and (not upcoming or season.startAt<upcoming.startAt) then upcoming=season end
    end
    if upcoming then return upcoming end
    local chosen=nil
    for _,season in ipairs(RPAL.SEASONS or {}) do
        if season.startAt<=now and (not chosen or season.startAt>chosen.startAt) then chosen=season end
    end
    return chosen or (RPAL.SEASONS and RPAL.SEASONS[1])
end

local function EnsureQuestSets(season,state,now)
    now=tonumber(now) or time()
    local revision=tonumber(RPAL.SEASON_QUEST_REVISION) or 1
    local dkey=DailyKey(now)
    if state.daily.key~=dkey or tonumber(state.daily.revision)~=revision then
        state.daily={key=dkey,revision=revision,quests=Pick(DAILY_POOL,3,season.id..":"..dkey..":"..revision),rerolls=1}
    else
        state.daily.quests=type(state.daily.quests)=="table" and state.daily.quests or Pick(DAILY_POOL,3,season.id..":"..dkey..":"..revision)
        if state.daily.rerolls==nil then state.daily.rerolls=1 end
    end
    local wkey=WeeklyKey(now)
    if state.weekly.key~=wkey or tonumber(state.weekly.revision)~=revision then
        state.weekly={key=wkey,revision=revision,quests=Pick(WEEKLY_POOL,5,season.id..":"..wkey..":"..revision),rerolls=1}
    else
        state.weekly.quests=type(state.weekly.quests)=="table" and state.weekly.quests or Pick(WEEKLY_POOL,5,season.id..":"..wkey..":"..revision)
        if state.weekly.rerolls==nil then state.weekly.rerolls=1 end
    end
end

function RPAL.GetSeasonQuestPreview(scope,seasonID)
    local season=(seasonID and RPAL.GetSeasonByID and RPAL.GetSeasonByID(seasonID)) or (RPAL.GetDisplayedSeason and RPAL.GetDisplayedSeason())
    if not season then return {quests={},rerolls=0,preview=true} end
    local weekly=(tostring(scope or ""):lower()=="weekly")
    local pool=weekly and WEEKLY_POOL or DAILY_POOL
    local count=weekly and 5 or 3
    local bucket={quests=Pick(pool,count,tostring(season.id)..":quest-preview:"..tostring(RPAL.SEASON_QUEST_REVISION or 1)),rerolls=0,preview=true}
    return bucket
end

local function SeasonLevel(season,xp)
    xp=math.max(0,math.floor(tonumber(xp) or 0))
    local level=1
    for index,threshold in ipairs(season.thresholds or {}) do
        if xp>=threshold then level=index else break end
    end
    level=math.min(season.maxLevel or #season.thresholds,math.max(1,level))
    local current=season.thresholds[level] or 0
    local nextThreshold=season.thresholds[level+1]
    if not nextThreshold then return level,0,1,1 end
    local progress=math.max(0,xp-current)
    local needed=math.max(1,nextThreshold-current)
    return level,progress,needed,math.min(1,progress/needed)
end
RPAL.GetSeasonLevelData=SeasonLevel

local function NotifyChanged(reason)
    if RPAL.RefreshSeasonUI then pcall(RPAL.RefreshSeasonUI,reason) end
    if RPAL.RefreshSeasonStrip then pcall(RPAL.RefreshSeasonStrip,reason) end
    if RPAL.RefreshSeasonTasks then pcall(RPAL.RefreshSeasonTasks,reason) end
end

local function GrantSingleSeasonReward(season,state,level,reward,silent,toastBucket)
    if not reward then return end
    local seasonLabel=string.upper(tostring(season and season.name or "ODYN SEASON"))
    if reward.kind=="token" then
        if RPAL.AddCrests then RPAL.AddCrests(reward.amount or 0) end
        if not silent and RPAL.ShowRewardToast then RPAL.ShowRewardToast(seasonLabel.." REWARD",reward.amount or 0,"SEASON LEVEL "..tostring(level)) end
    elseif reward.kind=="packs" then
        local credit=state.legacyPackCredits and math.max(0,math.floor(tonumber(state.legacyPackCredits[level]) or 0)) or 0
        local amount=math.max(0,math.floor(tonumber(reward.amount) or 1)-credit)
        if state.legacyPackCredits then state.legacyPackCredits[level]=nil end
        if amount>0 and RPAL.AddHearthstonePacks then RPAL.AddHearthstonePacks(amount,"season:"..tostring(season and season.id or "season")) end
        if amount>0 and not silent and RPAL.ShowHearthstonePackRewardToast then RPAL.ShowHearthstonePackRewardToast(amount,seasonLabel.." • LEVEL "..tostring(level)) end
    elseif reward.kind=="theme" then
        if RPAL.UnlockTheme then RPAL.UnlockTheme(reward.name,"progression") end
        toastBucket[#toastBucket+1]={kind="theme",name=reward.label or reward.name}
    elseif reward.kind=="cosmetic" then
        if RPAL.UnlockCosmetic then RPAL.UnlockCosmetic(reward.cosmeticKind,reward.name,"progression") end
        toastBucket[#toastBucket+1]={kind=reward.cosmeticKind,name=reward.label or reward.name}
    elseif reward.kind and reward.name then
        if RPAL.UnlockCosmetic then RPAL.UnlockCosmetic(reward.kind,reward.name,"progression") end
        toastBucket[#toastBucket+1]={kind=reward.kind,name=reward.label or reward.name}
    end
end

local function GrantReward(season,state,level,reward,silent)
    if not reward or state.claimedLevels[level] then return false end
    state.claimedLevels[level]=true
    local toastBucket={}
    GrantSingleSeasonReward(season,state,level,reward,silent,toastBucket)
    for _,bonus in ipairs(reward.bonusRewards or {}) do GrantSingleSeasonReward(season,state,level,bonus,silent,toastBucket) end
    if not silent and #toastBucket>0 and RPAL.ShowCosmeticRewardToast then
        RPAL.ShowCosmeticRewardToast(toastBucket,string.upper(tostring(season and season.name or "ODYN SEASON")).." • LEVEL "..tostring(level))
    end
    return true
end

local function GrantReachedRewards(season,state,oldLevel,newLevel,silent)
    for level=1,newLevel do
        local reward=season.rewards and season.rewards[level]
        if reward and not state.claimedLevels[level] then GrantReward(season,state,level,reward,silent) end
    end
end

function RPAL.AddSeasonXP(amount,source,silent)
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return false,"Seasons are unavailable in Lite Kit." end
    local season=RPAL.GetActiveSeason()
    if not season then return false,"No active ODYN season." end
    local state=EnsureSeasonState(season)
    if not state then return false,"Season data is not ready." end
    EnsureQuestSets(season,state,time())
    local oldLevel=select(1,SeasonLevel(season,state.xp))
    amount=math.max(0,math.floor(tonumber(amount) or 0))
    if amount<=0 then return false,"No Season XP awarded." end
    state.xp=state.xp+amount
    state.lastXPGainAt=time();state.lastXPSource=tostring(source or "season")
    local newLevel=select(1,SeasonLevel(season,state.xp))
    -- Silent XP gains (the 5 Renown conversion) should not spam a toast, but
    -- crossing a Season level must still announce/unlock its actual reward.
    GrantReachedRewards(season,state,oldLevel,newLevel,false)
    if not silent and RPAL.ShowRewardToast then RPAL.ShowRewardToast("Season XP",amount,string.upper(tostring(season.name or "ODYN SEASON"))) end
    NotifyChanged("xp")
    return true,{amount=amount,oldLevel=oldLevel,newLevel=newLevel,xp=state.xp}
end

local function CompleteQuest(quest,scope)
    if not quest or quest.awarded then return end
    quest.complete=true
    quest.awarded=true
    quest.completedAt=time()
    -- Quest completion owns its presentation.  Add the SXP silently so the
    -- normal gold proc toast does not compete with the purple Season toast.
    RPAL.AddSeasonXP(quest.xp or 0,(scope or "season")..":"..tostring(quest.id),true)
    if RPAL.ShowSeasonQuestToast then
        RPAL.ShowSeasonQuestToast(scope or "season",quest.title or "Season Quest",quest.xp or 0)
    end
end

local function ApplyMetricToQuest(quest,metric,amount,context,scope)
    if not quest or quest.complete or quest.metric~=metric then return false end
    if metric=="rp_days" then
        local day=tostring((context and context.day) or DailyKey())
        quest.seenDays=type(quest.seenDays)=="table" and quest.seenDays or {}
        if quest.seenDays[day] then return false end
        quest.seenDays[day]=true
        quest.progress=(tonumber(quest.progress) or 0)+1
    else
        quest.progress=(tonumber(quest.progress) or 0)+math.max(0,tonumber(amount) or 0)
    end
    if quest.progress>=(tonumber(quest.target) or 1) then
        quest.progress=tonumber(quest.target) or quest.progress
        CompleteQuest(quest,scope)
    end
    return true
end

local function NormalizeSeasonStats(stats)
    stats=type(stats)=="table" and stats or {}
    stats.sessions=math.max(0,math.floor(tonumber(stats.sessions) or 0))
    stats.rpMinutes=math.max(0,math.floor(tonumber(stats.rpMinutes) or 0))
    stats.charactersMet=math.max(0,math.floor(tonumber(stats.charactersMet) or 0))
    stats.emotesOut=math.max(0,math.floor(tonumber(stats.emotesOut) or 0))
    stats.emotesReceived=math.max(0,math.floor(tonumber(stats.emotesReceived) or 0))
    stats.renownGained=math.max(0,math.floor(tonumber(stats.renownGained) or 0))
    stats.longestSession=math.max(0,math.floor(tonumber(stats.longestSession) or 0))
    stats.days=type(stats.days)=="table" and stats.days or {}
    return stats
end

local function EnsureCharacterSeasonStats(season)
    if not season then return nil end
    if type(RPAL_CHAR_DB)=="table" then
        RPAL_CHAR_DB.seasonCharacterStats=type(RPAL_CHAR_DB.seasonCharacterStats)=="table" and RPAL_CHAR_DB.seasonCharacterStats or {}
        local stats=NormalizeSeasonStats(RPAL_CHAR_DB.seasonCharacterStats[season.id])
        RPAL_CHAR_DB.seasonCharacterStats[season.id]=stats
        return stats
    end
    -- Defensive fallback for unusually early load order. Normal runtime uses
    -- RPAL_CHAR_DB so two characters on the same account never share these stats.
    local state=EnsureSeasonState(season);if not state then return nil end
    state.stats=NormalizeSeasonStats(state.stats);return state.stats
end

local function RecordSeasonStat(stats,metric,amount,context)
    if not stats then return end
    amount=math.max(0,tonumber(amount) or 0)
    if metric=="session_count" then stats.sessions=(tonumber(stats.sessions) or 0)+amount
    elseif metric=="rp_minutes" then stats.rpMinutes=(tonumber(stats.rpMinutes) or 0)+amount
    elseif metric=="characters_met" then stats.charactersMet=(tonumber(stats.charactersMet) or 0)+amount
    elseif metric=="emotes_out" then stats.emotesOut=(tonumber(stats.emotesOut) or 0)+amount
    elseif metric=="emotes_received" then stats.emotesReceived=(tonumber(stats.emotesReceived) or 0)+amount
    elseif metric=="renown_gain" then stats.renownGained=(tonumber(stats.renownGained) or 0)+amount
    elseif metric=="rp_days" then
        local day=tostring((context and context.day) or DailyKey())
        stats.days=type(stats.days)=="table" and stats.days or {};stats.days[day]=true
    end
end

function RPAL.GetSeasonCharacterStats(seasonID)
    local season=seasonID and RPAL.GetSeasonByID(seasonID) or RPAL.GetDisplayedSeason()
    if not season then return nil end
    local stats=EnsureCharacterSeasonStats(season) or {};local dayCount=0;for _ in pairs(stats.days or {}) do dayCount=dayCount+1 end
    return {
        sessions=math.floor(tonumber(stats.sessions) or 0),
        rpMinutes=math.floor(tonumber(stats.rpMinutes) or 0),
        charactersMet=math.floor(tonumber(stats.charactersMet) or 0),
        emotesOut=math.floor(tonumber(stats.emotesOut) or 0),
        emotesReceived=math.floor(tonumber(stats.emotesReceived) or 0),
        renownGained=math.floor(tonumber(stats.renownGained) or 0),
        longestSession=math.floor(tonumber(stats.longestSession) or 0),
        rpDays=dayCount,
    }
end

function RPAL.RecordSeasonMetric(metric,amount,context)
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return false end
    local season=RPAL.GetActiveSeason()
    if not season then return false end
    local state=EnsureSeasonState(season)
    if not state then return false end
    EnsureQuestSets(season,state,time())
    RecordSeasonStat(EnsureCharacterSeasonStats(season),metric,amount,context)
    local changed=false
    for _,quest in ipairs(state.daily.quests or {}) do
        if ApplyMetricToQuest(quest,metric,amount,context,"daily") then changed=true end
    end
    for _,quest in ipairs(state.weekly.quests or {}) do
        if ApplyMetricToQuest(quest,metric,amount,context,"weekly") then changed=true end
    end
    if changed then NotifyChanged("quest") end
    return changed
end

local function ParticipantCount(scene)
    local count=1 -- player
    for _ in pairs((scene and scene.participants) or {}) do count=count+1 end
    return count
end

function RPAL.RecordSeasonSession(scene)
    if not scene then return end
    local duration=RPAL.GetSceneDuration and RPAL.GetSceneDuration(scene) or tonumber(scene.duration) or tonumber(scene.activeSeconds) or 0
    local minutes=math.max(0,math.floor((tonumber(duration) or 0)/60))
    local season=RPAL.GetActiveSeason and RPAL.GetActiveSeason()
    if season then
        local stats=EnsureCharacterSeasonStats(season)
        if stats then stats.longestSession=math.max(tonumber(stats.longestSession) or 0,math.floor(tonumber(duration) or 0)) end
    end
    RPAL.RecordSeasonMetric("session_count",1,{scene=scene})
    if minutes>0 then RPAL.RecordSeasonMetric("rp_minutes",minutes,{scene=scene}) end
    RPAL.RecordSeasonMetric("rp_days",1,{day=DailyKey(tonumber(scene.endedAt) or time()),scene=scene})
    if duration>=1800 then RPAL.RecordSeasonMetric("session_30",1,{scene=scene}) end
    if duration>=3600 then RPAL.RecordSeasonMetric("session_60",1,{scene=scene}) end
    if duration>=5400 then RPAL.RecordSeasonMetric("session_90",1,{scene=scene}) end
end

function RPAL.RerollSeasonQuest(scope,questRef)
    local season=RPAL.GetActiveSeason()
    if not season then return false,"No active season." end
    local state=EnsureSeasonState(season)
    EnsureQuestSets(season,state,time())
    local bucket=(scope=="weekly") and state.weekly or state.daily
    local pool=(scope=="weekly") and WEEKLY_POOL or DAILY_POOL
    if (tonumber(bucket.rerolls) or 0)<=0 then return false,"No rerolls remaining." end
    local index=nil
    if type(questRef)=="number" then
        local candidate=bucket.quests and bucket.quests[math.floor(questRef)]
        if candidate and not candidate.complete then index=math.floor(questRef) end
    elseif questRef~=nil then
        local wanted=tostring(questRef)
        for i,quest in ipairs(bucket.quests or {}) do if tostring(quest.id)==wanted and not quest.complete then index=i;break end end
    else
        for i,quest in ipairs(bucket.quests or {}) do if not quest.complete then index=i;break end end
    end
    if not index then return false,"That quest cannot be rerolled." end
    local used={}
    for _,quest in ipairs(bucket.quests or {}) do used[quest.id]=true end
    local replacement=Pick(pool,1,season.id..":"..tostring(bucket.key)..":reroll:"..tostring(bucket.rerolls)..":"..tostring(index),used)[1]
    if not replacement then return false,"No alternate quest is available." end
    bucket.quests[index]=replacement
    bucket.rerolls=math.max(0,(tonumber(bucket.rerolls) or 0)-1)
    NotifyChanged("reroll")
    return true,replacement,index
end


function RPAL.IsSeasonRewardClaimed(kind,name)
    local lookup=RPAL.SEASON_REWARD_LOOKUP and RPAL.SEASON_REWARD_LOOKUP[tostring(kind or "")..":"..tostring(name or "")]
    if not lookup then return false end
    local season=RPAL.GetSeasonByID and RPAL.GetSeasonByID(lookup.season)
    if not season then return false end
    local state=EnsureSeasonState(season)
    return state and state.claimedLevels and state.claimedLevels[tonumber(lookup.level) or 0] and true or false
end

function RPAL.GetSeasonSnapshot(now)
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return nil end
    now=tonumber(now) or time()
    local season=RPAL.GetDisplayedSeason(now)
    if not season then return nil end
    local state=EnsureSeasonState(season)
    if not state then return nil end
    if now>=season.startAt and now<season.endAt then EnsureQuestSets(season,state,now) end
    local level,progress,needed,percent=SeasonLevel(season,state.xp)
    local remaining=math.max(0,season.endAt-now)
    return {
        season=season,state=state,level=level,progress=progress,needed=needed,percent=percent,
        xp=state.xp,active=now>=season.startAt and now<season.endAt,ended=now>=season.endAt,
        startsIn=math.max(0,season.startAt-now),remaining=remaining,
        daysLeft=math.max(0,math.ceil(remaining/86400)),
        daily=state.daily,weekly=state.weekly,stats=RPAL.GetSeasonCharacterStats and RPAL.GetSeasonCharacterStats(season.id) or state.stats,
    }
end


function RPAL.ArmSeasonStartNotification()
    if RPAL._seasonStartWatchTimer and RPAL._seasonStartWatchTimer.Cancel then RPAL._seasonStartWatchTimer:Cancel() end
    RPAL._seasonStartWatchTimer=nil
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return false end
    if not C_Timer or not C_Timer.NewTimer then return false end

    local now=time()
    local upcoming=nil
    for _,season in ipairs(RPAL.SEASONS or {}) do
        if season.startAt and season.startAt>now and (not upcoming or season.startAt<upcoming.startAt) then upcoming=season end
    end
    if not upcoming then return false end
    local seasonID=tostring(upcoming.id or upcoming.name or upcoming.startAt)
    local remaining=math.max(0,upcoming.startAt-now)
    local delay=remaining>300 and 300 or math.max(0.10,remaining+0.12)
    RPAL._seasonStartWatchTimer=C_Timer.NewTimer(delay,function()
        RPAL._seasonStartWatchTimer=nil
        local current=time()
        if current>=upcoming.startAt and current<upcoming.endAt then
            if RPAL._seasonStartNotifiedSessionID~=seasonID then
                RPAL._seasonStartNotifiedSessionID=seasonID
                if RPAL.ShowSeasonStartedReloadNotification then RPAL.ShowSeasonStartedReloadNotification(upcoming) end
            end
            return
        end
        if current<upcoming.startAt and RPAL.ArmSeasonStartNotification then RPAL.ArmSeasonStartNotification() end
    end)
    return true
end

function RPAL.InitializeSeasonSystem()
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then
        NotifyChanged("lite")
        return true
    end
    local root=EnsureRoot()
    if not root then return false end
    local season=RPAL.GetDisplayedSeason()
    if season then
        local state=EnsureSeasonState(season)
        local now=time()
        local displayedIsActive=now>=season.startAt and now<season.endAt
        if displayedIsActive then
            EnsureQuestSets(season,state,now)
            local level=select(1,SeasonLevel(season,state.xp))
            GrantReachedRewards(season,state,1,level,true)
        end
    end
    NotifyChanged("init")
    if RPAL.ArmSeasonStartNotification then RPAL.ArmSeasonStartNotification() end
    return true
end

-- Mutation hooks ------------------------------------------------------------
local function Wrap(name,after)
    local original=RPAL[name]
    if type(original)~="function" or RPAL["_seasonWrapped_"..name] then return end
    RPAL["_seasonWrapped_"..name]=true
    RPAL[name]=function(...)
        local results={original(...)}
        pcall(after,results,...)
        return unpack(results)
    end
end

Wrap("EndScene",function(results)
    if results[1] and type(results[2])=="table" then RPAL.RecordSeasonSession(results[2]) end
end)
Wrap("EndStoredScene",function(results)
    if results[1] and type(results[2])=="table" then RPAL.RecordSeasonSession(results[2]) end
end)
Wrap("RecordCharacterMet",function(results)
    if results[1] and results[2]=="unique" then RPAL.RecordSeasonMetric("characters_met",1) end
end)

-- Simple social Season objectives intentionally use the same eligible targeted
-- interactions as ODYN's normal statistics.  This keeps the quests useful to
-- every roleplayer without requiring Story Arcs, DM Kit or mini-games.
Wrap("RecordStatistic",function(results)
    if results[1] then RPAL.RecordSeasonMetric("emotes_received",1) end
end)

local function ShortName(value)
    value=tostring(value or "")
    return value:match("^([^%-]+)") or value
end

Wrap("HandleTextEmote",function(_,message,sender,...)
    if type(message)~="string" then return end
    local own=ShortName(UnitName and UnitName("player") or "")
    local who=ShortName(sender)
    if own=="" or who=="" or own:lower()~=who:lower() then return end
    if not UnitExists or not UnitExists("target") or not UnitIsPlayer or not UnitIsPlayer("target") then return end
    if UnitIsUnit and UnitIsUnit("target","player") then return end
    local target=ShortName(UnitName and UnitName("target") or "")
    if target=="" or not tostring(message):lower():find(target:lower(),1,true) then return end
    RPAL.RecordSeasonMetric("emotes_out",1,{target=target})
end)

local function RecordRenownForSeason(gained,source,options)
    gained=math.max(0,math.floor(tonumber(gained) or 0))
    if gained<=0 or tostring(source or "")=="legacy_calibration" then return end
    local season=RPAL.GetActiveSeason()
    if not season then return end
    local state=EnsureSeasonState(season)
    if not state then return end

    RPAL.RecordSeasonMetric("renown_gain",gained,{source=source})

    -- Five earned Renown normally converts into one Season XP.  Fixed grants
    -- may explicitly provide their own SXP reward (the Daily RP Reward does),
    -- in which case skip only this conversion while still advancing Renown quests.
    if not (type(options)=="table" and options.skipSeasonXP) then
        local carry=math.max(0,math.floor(tonumber(state.renownSXPCarry) or 0))+gained
        local sxp=math.floor(carry/5)
        state.renownSXPCarry=carry%5
        if sxp>0 then RPAL.AddSeasonXP(sxp,"renown_conversion",true) end
    end
end

Wrap("AddRenown",function(results,...)
    local args={...}
    RecordRenownForSeason(results[1],args[2],args[3])
end)
