-- Cadence: Schedule.lua -- turns a saved timeline into cues.
--
-- Two trigger sources, both allowed under the Midnight addon rules because
-- neither reads combat state:
--   PULL    -- a plain clock started by ENCOUNTER_START
--   BOSSMOD -- a bar started by BigWigs or DBM, which get their timings from
--              encounter events rather than from combat log scraping
--
-- Nothing here inspects your cooldowns, your buffs, or anyone's health. It only
-- knows what time it is.

local ADDON, ns = ...

ns.Schedule = {}
local Sched = ns.Schedule

local timers = {}          -- pending C_Timer handles
local groups = {}          -- cues that come up at the same moment, batched
local currentID = nil      -- encounterID currently running
local pullTime = nil
local barCounts = {}       -- match key -> how many times that bar has started
local hookedBW = false
local hookedDBM = false
local testing = false
local testTimer = nil

--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------

-- Capped at the cue's own pull time, so nothing tries to count down from before
-- the fight started. See ns.EffectiveLead in Core.lua.
local function LeadFor(reminder)
    return ns.EffectiveLead(reminder)
end

local function Queue(delay, fn)
    if delay < 0 then delay = 0 end
    timers[#timers + 1] = C_Timer.NewTimer(delay, fn)
end

-- Reminders that would appear at the same instant share one timer, so they arrive
-- in the same frame and in a stable order rather than racing each other.
-- A cue whose moment has genuinely gone is dropped, but "exactly now" is not
-- past. A reminder at 0:00 is dispatched in the same frame as the pull, and
-- GetTime() is frame-constant, so fireAt == now to the bit -- a <= test silently
-- swallowed it. Worse, a compressed test run always schedules ahead of now, so
-- the cue showed in testing and vanished on the pull.
local DISPATCH_GRACE = 0.25

local function DispatchAt(reminder, fireAt)
    local now = GetTime()
    local showAt = fireAt - LeadFor(reminder)
    -- If the cue would already have started, show it with whatever lead is left.
    if fireAt < now - DISPATCH_GRACE then return end

    -- Both trigger types come through here, so the upcoming list stays complete
    -- whether a cue came from the pull clock or from a boss-mod bar.
    ns.Queue:Add(reminder, fireAt)

    local delay = math.max(0, showAt - now)
    local key = ("%.2f"):format(now + delay)

    local group = groups[key]
    if group then
        group.items[#group.items + 1] = { r = reminder, fireAt = fireAt }
        return
    end

    group = { items = { { r = reminder, fireAt = fireAt } } }
    groups[key] = group

    group.timer = C_Timer.NewTimer(delay, function()
        groups[key] = nil
        table.sort(group.items, function(a, b)
            if a.fireAt ~= b.fireAt then return a.fireAt < b.fireAt end
            return tostring(a.r.label or "") < tostring(b.r.label or "")
        end)
        ns.Display:ShowGroup(group.items)
    end)
    timers[#timers + 1] = group.timer
end

--------------------------------------------------------------------------------
-- Boss mod hooks
--------------------------------------------------------------------------------

-- Remembers every bar a boss mod starts during a pull, so the editor can offer a
-- real list of that boss's abilities instead of asking you to type the text.
-- Boss mods label repeat casts with a running count, so "Venom Nova (3)" would
-- otherwise be learned as a different ability from "Venom Nova (2)" and the list
-- would fill with near-duplicates. Matching is a substring test anyway, so the
-- shorter key is also the more useful one to anchor against.
local function BarKey(text)
    text = tostring(text)
    local trimmed = text:gsub("%s*%(%s*%d+%s*%)%s*$", "")
    if trimmed ~= "" then return trimmed end
    return text
end

-- Hard ceiling on how many distinct abilities are remembered per boss. This lives
-- in saved variables, so an unusual boss mod that labels every bar differently
-- must not be able to grow the file forever.
local MAX_LEARNED_BARS = 200

local function RecordBar(encounterID, text, duration)
    local enc = ns.GetEncounter(encounterID, true)
    enc.bars = enc.bars or {}

    local key = BarKey(text)
    local bar = enc.bars[key]
    if not bar then
        local known = 0
        for _ in pairs(enc.bars) do known = known + 1 end
        if known >= MAX_LEARNED_BARS then return end
        bar = { count = 0 }
        enc.bars[key] = bar
    end
    bar.count = bar.count + 1
    bar.duration = duration
    bar.firstSeen = bar.firstSeen or Sched:FightTime()
end

-- Called whenever BigWigs or DBM starts a bar. text is the bar label, duration
-- is how long until the ability happens.
function Sched:OnBossModBar(text, duration)
    if not currentID or not text or not duration or duration <= 0 then return end

    RecordBar(currentID, text, duration)

    local lower = tostring(text):lower()

    for _, r in ipairs(ns.GetReminders(currentID)) do
        if r.enabled and r.trigger == "BOSSMOD" and r.match and r.match ~= "" then
            local key = r.match:lower()
            if lower:find(key, 1, true) then
                barCounts[r.id] = (barCounts[r.id] or 0) + 1
                local want = r.occurrence or 1
                if want == 0 or want == barCounts[r.id] then
                    DispatchAt(r, GetTime() + duration + (r.offset or 0))
                end
            end
        end
    end
end

local function HookBossMods()
    -- BigWigs. Signature verified against the BigWigs public message API; if a
    -- future build changes it the pcall keeps us from erroring on login.
    if not hookedBW and BigWigsLoader and BigWigsLoader.RegisterMessage then
        hookedBW = pcall(function()
            BigWigsLoader.RegisterMessage(Sched, "BigWigs_StartBar",
                function(_, _, _, text, time) Sched:OnBossModBar(text, time) end)
        end)
    end

    -- DBM.
    if not hookedDBM and DBM and DBM.RegisterCallback then
        hookedDBM = pcall(function()
            DBM:RegisterCallback("DBM_TimerStart",
                function(_, _, msg, duration) Sched:OnBossModBar(msg, duration) end)
        end)
    end
end

function Sched:Init()
    HookBossMods()
    -- Boss mods often load after us, so try again once the world is up -- but only
    -- for the one still missing. BigWigs keys its callbacks on (target, event) and
    -- would replace ours, while DBM appends without deduplicating: registering it
    -- twice fires every bar twice, which double-counts occurrence and shows each
    -- anchored cue as a pair.
    C_Timer.After(5, HookBossMods)
end

function Sched:HasBossMod()
    return (BigWigsLoader ~= nil) or (DBM ~= nil)
end

--------------------------------------------------------------------------------
-- Encounter lifecycle
--------------------------------------------------------------------------------

-- If a plan is tagged with this difficulty, switch to it. Saves picking the
-- right one by hand when the raid moves from heroic to mythic.
local function AutoSelectPlan(encounterID, difficultyID)
    if not difficultyID or not GetDifficultyInfo then return nil end
    local ok, name = pcall(GetDifficultyInfo, difficultyID)
    if not ok or not name then return nil end

    local lower = tostring(name):lower()
    for _, plan in ipairs(ns.GetPlans(encounterID)) do
        if plan.difficultyName and plan.difficultyName:lower() == lower then
            local active = ns.GetActivePlan(encounterID)
            if not active or active.id ~= plan.id then
                ns.SetActivePlan(encounterID, plan.id)
                return plan
            end
            return nil
        end
    end
    return nil
end

function Sched:StartEncounter(encounterID, encounterName, difficultyID)
    self:Stop()
    ns.Queue:Clear()

    currentID = encounterID
    pullTime = GetTime()
    barCounts = {}

    local switched = AutoSelectPlan(encounterID, difficultyID)
    if switched then ns.Print("Switched to the %s plan.", switched.name) end

    local plan = ns.GetActivePlan(encounterID)
    if not plan or #plan.reminders == 0 then return end

    local pullCount = 0
    for _, r in ipairs(plan.reminders) do
        if r.enabled and r.trigger == "PULL" then
            pullCount = pullCount + 1
            DispatchAt(r, pullTime + (r.at or 0))
        end
    end

    ns.Print("%s (%s) — %d timed reminders armed.",
        encounterName or "Encounter", plan.name or "plan", pullCount)
end

function Sched:IsTesting() return testing end

function Sched:Stop()
    testing = false
    if testTimer and testTimer.Cancel then testTimer:Cancel() end
    testTimer = nil
    for _, t in ipairs(timers) do
        if t and t.Cancel then t:Cancel() end
    end
    wipe(timers)
    wipe(groups)
    wipe(barCounts)
    currentID, pullTime = nil, nil
    ns.Display:ClearAll()
    ns.Queue:Stop()
end

function Sched:IsRunning() return currentID ~= nil end

function Sched:FightTime()
    return pullTime and (GetTime() - pullTime) or 0
end

--------------------------------------------------------------------------------
-- Test run
--------------------------------------------------------------------------------

-- Plays back the selected boss's timeline so you can check placement and volume
-- without a raid.
--
-- Compressed by default, but cues that share a time stay together: the preview
-- steps through DISTINCT times, not individual reminders. Spacing them one by one
-- would hide the very overlaps you are testing for.
--
-- Pass realTiming to play the plan at its true pull times instead.
function Sched:TestRun(encounterID, realTiming)
    encounterID = encounterID or (ns.Options and ns.Options.selectedEncounter)
    local plan = encounterID and ns.GetActivePlan(encounterID)
    if not plan or #plan.reminders == 0 then
        -- Fall back to a single sample cue so you can still place the anchor.
        self:Stop()
        local sample = ns.GetClassCooldowns()[1]
        local lead = CadenceDB.settings.lead or 5
        ns.Display:ShowCue({
            label = sample and sample.name or "Cooldown",
            spellID = sample and sample.spellID,
            note = "test cue",
        }, GetTime() + lead)
        testing = true
        testTimer = C_Timer.NewTimer(lead + 3, function()
            testing = false
            testTimer = nil
            if ns.Options and ns.Options.RefreshTestButton then ns.Options:RefreshTestButton() end
        end)
        return
    end

    self:Stop()
    pullTime = GetTime()
    currentID = encounterID

    local pullOnly = {}
    for _, r in ipairs(plan.reminders) do
        if r.enabled and r.trigger == "PULL" then pullOnly[#pullOnly + 1] = r end
    end
    table.sort(pullOnly, function(a, b) return (a.at or 0) < (b.at or 0) end)

    -- Group reminders that share a time so they are dispatched as one moment.
    local moments, byTime = {}, {}
    for _, r in ipairs(pullOnly) do
        local at = r.at or 0
        if not byTime[at] then
            byTime[at] = { at = at, items = {} }
            moments[#moments + 1] = byTime[at]
        end
        table.insert(byTime[at].items, r)
    end

    local spacing = (CadenceDB.settings.lead or 5) + 2
    local total = 0

    for i, moment in ipairs(moments) do
        local when = realTiming and (pullTime + moment.at) or (GetTime() + i * spacing)
        total = math.max(total, when - GetTime())
        for _, r in ipairs(moment.items) do
            DispatchAt(r, when)
        end
    end

    testing = true
    testTimer = C_Timer.NewTimer(total + 3, function()
        testing = false
        testTimer = nil
        if ns.Options and ns.Options.RefreshTestButton then ns.Options:RefreshTestButton() end
    end)

    ns.Print("Test run: %d cues across %d moments from %s%s.",
        #pullOnly, #moments, plan.name or "the plan",
        realTiming and " at real timing" or "")
end
