-- Cadence: Options.lua -- the window, the timeline and the reminder editor.
--
-- Everything is anchored relatively rather than at fixed pixel offsets, so the
-- window resizes cleanly and a label that grows cannot land on its neighbour.

local ADDON, ns = ...

ns.Options = {}
local O = ns.Options
local FONT = "Fonts\\FRIZQT__.TTF"

local main, panel, editor, dialog, timeline

local RIGHT_W = 210          -- alerts column
local GUTTER_W = 150         -- ability-name column down the left of the timeline
local LANE_H = 26            -- one row per ability
local BLOCK_H = 18           -- the cue bar inside a row
local MIN_BLOCK = 22         -- a short countdown still needs to be readable and clickable
local RULER_H = 16           -- the clock strip above the rows
local PAD = 12               -- canvas margin before t=0
local MIN_ZOOM, MAX_ZOOM = 0.3, 10
local MIN_W, MIN_H = 760, 520

--------------------------------------------------------------------------------
-- Small widget helpers
--------------------------------------------------------------------------------

local function Label(parent, text, size, x, y, relTo, relPoint)
    local fs = parent:CreateFontString(nil, "ARTWORK")
    fs:SetFont(FONT, size or 12, "")
    fs:SetPoint("TOPLEFT", relTo or parent, relPoint or "TOPLEFT", x or 0, y or 0)
    fs:SetText(text)
    fs:SetJustifyH("LEFT")
    return fs
end

local function Button(parent, text, w, h)
    local b = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    b:SetSize(w or 90, h or 22)
    b:SetText(text)
    return ns.TintButton(b)
end

local function Tint(fs, name)
    local c = ns.COLORS[name] or ns.COLORS.bone
    if fs and fs.SetTextColor then fs:SetTextColor(c[1], c[2], c[3]) end
    return fs
end

local function EditBox(parent, w)
    local e = CreateFrame("EditBox", nil, parent, "InputBoxTemplate")
    e:SetSize(w or 90, 20)
    e:SetAutoFocus(false)
    e:SetFontObject("GameFontHighlightSmall")
    e:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    e:SetScript("OnEnterPressed", function(self) self:ClearFocus() end)
    return e
end

local function CheckBox(parent, text)
    local c = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate")
    c:SetSize(24, 24)
    if text then
        c.text = c:CreateFontString(nil, "ARTWORK")
        c.text:SetFont(FONT, 12, "")
        c.text:SetPoint("LEFT", c, "RIGHT", 2, 0)
        c.text:SetText(text)
    end
    return c
end

-- Menus grow past the screen once a boss has a lot of abilities or a media pack
-- registers a lot of sounds. SetScrollMode caps the height and scrolls instead.
--
-- Only ever call this on a menu that holds ENTRIES. A scrolling menu whose
-- children are themselves submenus positions those submenus over the top of the
-- parent, which is what made the spell picker overlap.
local function Scrollable(desc)
    pcall(function()
        local cap = 360
        if UIParent and UIParent.GetHeight then
            cap = math.max(200, math.min(420, UIParent:GetHeight() * 0.45))
        end
        desc:SetScrollMode(cap)
    end)
    return desc
end

local function Dropdown(parent, width, generator, currentText)
    local dd
    local ok = pcall(function()
        dd = CreateFrame("DropdownButton", nil, parent, "WowStyle1DropdownTemplate")
    end)
    if not ok or not dd then
        dd = Button(parent, "", width, 22)
        dd.SetupMenu = function(self, gen)
            self:SetScript("OnClick", function(self_)
                if MenuUtil and MenuUtil.CreateContextMenu then
                    MenuUtil.CreateContextMenu(self_, function(_, root) gen(self_, root) end)
                end
            end)
        end
        dd.SetDefaultText = function(self, t) self:SetText(t) end
    end
    dd:SetWidth(width or 180)
    dd.generator = generator     -- kept accessible for tests and /cadence debug
    dd:SetupMenu(generator)
    if currentText then dd:SetDefaultText(currentText) end
    return dd
end

--------------------------------------------------------------------------------
-- Boss selection
--------------------------------------------------------------------------------

O.selectedEncounter = nil

local function CurrentEncounterName()
    if not O.selectedEncounter then return "Choose a boss" end
    local enc = ns.GetEncounter(O.selectedEncounter)
    return (enc and enc.name) or CadenceDB.seen[O.selectedEncounter] or ("Encounter " .. O.selectedEncounter)
end

local function CurrentPlanName()
    local plan = O.selectedEncounter and ns.GetActivePlan(O.selectedEncounter)
    return (plan and plan.name) or "No plan"
end

local function RefreshHeader()
    if not panel then return end
    if panel.bossDropdown then panel.bossDropdown:SetDefaultText(CurrentEncounterName()) end
    if panel.planDropdown then
        local plans = O.selectedEncounter and ns.GetPlans(O.selectedEncounter) or {}
        panel.planDropdown:SetDefaultText(#plans > 1
            and ("%s  (%d)"):format(CurrentPlanName(), #plans)
            or CurrentPlanName())
    end
end

local function SelectEncounter(id)
    O.selectedEncounter = id
    ns.GetEncounter(id, true)
    RefreshHeader()
    O:RefreshList()
end

local function SelectPlan(planID)
    if not O.selectedEncounter then return end
    ns.SetActivePlan(O.selectedEncounter, planID)
    RefreshHeader()
    O:RefreshList()
end

-- Name entry for new and renamed plans.
StaticPopupDialogs = StaticPopupDialogs or {}
StaticPopupDialogs["CADENCE_PLAN_NAME"] = {
    text = "%s",
    button1 = ACCEPT or "Accept",
    button2 = CANCEL or "Cancel",
    hasEditBox = true,
    maxLetters = 40,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    OnShow = function(self, data)
        local box = self.editBox or (self.GetEditBox and self:GetEditBox())
        if box then
            box:SetText((data and data.default) or "")
            box:HighlightText()
            box:SetFocus()
        end
    end,
    OnAccept = function(self, data)
        local box = self.editBox or (self.GetEditBox and self:GetEditBox())
        local text = box and box:GetText() or ""
        if data and data.callback then data.callback(text) end
    end,
    EditBoxOnEnterPressed = function(self)
        local parent = self:GetParent()
        local data = parent.data
        if data and data.callback then data.callback(self:GetText() or "") end
        parent:Hide()
    end,
}

local function AskForName(prompt, default, callback)
    if not StaticPopup_Show then
        ns.Print("Cannot open a text prompt on this client.")
        return
    end
    StaticPopup_Show("CADENCE_PLAN_NAME", prompt, nil, { default = default, callback = callback })
end

--------------------------------------------------------------------------------
-- Shared reminder helpers
--------------------------------------------------------------------------------

local function DescribeTiming(r)
    if r.trigger == "BOSSMOD" then
        local off = r.offset or 0
        local when = off < 0 and ("%.0fs before"):format(-off)
                  or off > 0 and ("%.0fs after"):format(off)
                  or "on"
        local nth = (r.occurrence or 1) == 0 and "every cast" or ("cast #" .. (r.occurrence or 1))
        return ("%s %s, %s"):format(when, (r.match and r.match ~= "") and r.match or "the bar", nth)
    end
    return ns.FormatTime(r.at or 0) .. " into the fight"
end

local function ReminderName(r)
    if r.label and r.label ~= "" then return r.label end
    return ns.SpellLabel(r.spellID) or "?"
end

local function DeleteReminder(r)
    local plan = ns.GetActivePlan(O.selectedEncounter)
    if not plan then return end
    for i, v in ipairs(plan.reminders) do
        if v.id == r.id then table.remove(plan.reminders, i) break end
    end
    O:RefreshList()
end

local function DuplicateReminder(r)
    local plan = ns.GetActivePlan(O.selectedEncounter)
    if not plan then return end
    local copy = {}
    for k, v in pairs(r) do copy[k] = v end
    copy.id = ns.NewID()
    if copy.trigger == "PULL" then copy.at = (copy.at or 0) + 5 end
    plan.reminders[#plan.reminders + 1] = copy
    ns.Serialize:Sort(plan.reminders)
    O:RefreshList()
end

local function ShowCueMenu(owner, r)
    if MenuUtil and MenuUtil.CreateContextMenu then
        MenuUtil.CreateContextMenu(owner, function(_, root)
            pcall(function() root:CreateTitle(ReminderName(r)) end)
            root:CreateButton("Edit", function() O:OpenEditor(r) end)
            root:CreateButton(r.enabled and "Disable" or "Enable", function()
                r.enabled = not r.enabled
                O:RefreshList()
            end)
            root:CreateButton("Duplicate", function() DuplicateReminder(r) end)
            root:CreateDivider()
            root:CreateButton("Delete", function() DeleteReminder(r) end)
        end)
        return
    end
    -- No menu API: ctrl+right-click deletes, plain right-click edits.
    if IsControlKeyDown and IsControlKeyDown() then
        DeleteReminder(r)
        ns.Print("Deleted %s.", ReminderName(r))
    else
        O:OpenEditor(r)
    end
end

--------------------------------------------------------------------------------
-- Timeline
--------------------------------------------------------------------------------

local function Zoom()
    return CadenceDB.settings.zoom or 1.2
end

local function TimeToX(t)
    return PAD + math.max(0, t or 0) * Zoom()
end

local function XToTime(x)
    return math.max(0, (x - PAD) / Zoom())
end

-- Every cue for the same ability shares a row, so a plan reads as one line per
-- cooldown instead of a scatter of icons at different heights. The row order is
-- the order each ability first appears, which is also the order you will use them.
local function LaneKey(r)
    if r.spellID then return "s" .. r.spellID end
    return "n" .. (r.label or ""):lower()
end

local function LaneColor(index)
    local p = ns.LANE_COLORS
    return unpack(p[((index - 1) % #p) + 1])
end

-- Row order is remembered on the plan rather than derived from the cue times.
-- Deriving it meant that retiming one cue could renumber every row underneath the
-- cursor mid-edit, which is the last thing you want while placing cooldowns.
--
-- An ability that has never been placed goes on the end, in the order it is first
-- needed. Only a genuinely new row ever moves on its own.
--
-- Keys for abilities with no cues right now are kept, not pruned: deleting a
-- cooldown's last cue and putting it back is a normal way to try a variant, and
-- it should return to its own row rather than to the bottom. The cap stops that
-- from growing without limit on a plan that has churned through a lot of spells.
local MAX_LANE_ORDER = 64

local function OrderedLanes(plan, byKey, discovered)
    local order = plan and plan.laneOrder
    if plan and not order then
        order = {}
        plan.laneOrder = order
    end
    if not order then return discovered end

    local seen, lanes = {}, {}
    for _, key in ipairs(order) do
        local lane = byKey[key]
        if lane and not seen[key] then
            seen[key] = true
            lanes[#lanes + 1] = lane
        end
    end

    local added = 0
    for _, lane in ipairs(discovered) do
        if not seen[lane.key] then
            seen[lane.key] = true
            lanes[#lanes + 1] = lane
            order[#order + 1] = lane.key
            added = added + 1
        end
    end

    -- Only ever trim keys that have nothing on the timeline, newest first, so a
    -- row you can currently see is never the thing that gets dropped.
    if added > 0 and #order > MAX_LANE_ORDER then
        for i = #order, 1, -1 do
            if #order <= MAX_LANE_ORDER then break end
            if not byKey[order[i]] then table.remove(order, i) end
        end
    end

    return lanes
end

--------------------------------------------------------------------------------
-- Tooltips
--------------------------------------------------------------------------------

local function MarkerTooltip(self)
    local r = self.reminder
    if not r or self.isDragging then return end
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:AddLine(ReminderName(r))
    GameTooltip:AddLine(DescribeTiming(r), 0.8, 0.8, 0.8)
    if r.note and r.note ~= "" then GameTooltip:AddLine(r.note, 0.7, 0.7, 0.7) end
    local lead = ns.EffectiveLead(r)
    if lead <= 0 then
        GameTooltip:AddLine("Fires on the pull, with no countdown", 0.6, 0.6, 0.6)
    else
        GameTooltip:AddLine(("Countdown %ss"):format(lead), 0.6, 0.6, 0.6)
    end
    if r.mute then
        GameTooltip:AddLine("Sound: silent", 0.6, 0.6, 0.6)
    elseif r.blip then
        GameTooltip:AddLine("Sound: default blip only", 0.6, 0.6, 0.6)
    else
        GameTooltip:AddLine("Sound: voice clip + blip", 0.6, 0.6, 0.6)
    end
    if not r.enabled then GameTooltip:AddLine("Disabled", 1, 0.5, 0.5) end
    GameTooltip:AddLine(" ")
    if r.trigger == "PULL" then
        GameTooltip:AddLine("Drag to change the time", 0.4, 0.7, 1)
    end
    GameTooltip:AddLine("Left-click to enable or disable", 0.4, 0.7, 1)
    GameTooltip:AddLine("Right-click to edit, duplicate or delete", 0.4, 0.7, 1)
    GameTooltip:Show()
end

local function LaneTooltip(self)
    local lane = self.lane
    if not lane then return end
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:AddLine(lane.name)
    GameTooltip:AddLine(("%d cue%s in this plan"):format(#lane.cues, #lane.cues == 1 and "" or "s"),
        0.8, 0.8, 0.8)

    local times = {}
    for i, r in ipairs(lane.cues) do
        if i > 6 then times[#times + 1] = "…" break end
        times[#times + 1] = ns.FormatTime(r.at or 0)
    end
    GameTooltip:AddLine(table.concat(times, "  "), 0.6, 0.6, 0.6)

    GameTooltip:AddLine(" ")
    GameTooltip:AddLine(lane.anyEnabled and "Left-click to disable every cue on this row"
        or "Left-click to enable every cue on this row", 0.4, 0.7, 1)
    GameTooltip:AddLine("Drag to move this row up or down", 0.4, 0.7, 1)
    GameTooltip:Show()
end

--------------------------------------------------------------------------------
-- Dragging
--------------------------------------------------------------------------------

-- Dragging a block rewrites its pull time. The cursor holds the moment the cue
-- FIRES, not the left edge of the block -- the block starts at the head of its
-- countdown, so grabbing by the edge would shift everything by the lead.
local function OnDragUpdate(self)
    local canvas = timeline.canvas
    local scale = canvas:GetEffectiveScale()
    if not scale or scale == 0 then return end

    local cursorX = GetCursorPosition() / scale
    local left = canvas:GetLeft()
    if not left then return end

    local t = XToTime(cursorX - left)
    if not (IsShiftKeyDown and IsShiftKeyDown()) then
        t = math.floor(t * 2 + 0.5) / 2      -- snap to half a second
    end
    self.dragTime = t

    local lead = ns.EffectiveLead({ trigger = "PULL", at = t, lead = self.reminder.lead })
    self:ClearAllPoints()
    self:SetPoint("TOPLEFT", canvas, "TOPLEFT", TimeToX(t - lead), self.dragY or 0)

    timeline.dragText:SetText(ns.FormatTime(t))
    timeline.dragText:ClearAllPoints()
    timeline.dragText:SetPoint("BOTTOM", self, "TOP", 0, 3)
    timeline.dragText:Show()
end

local function StartMarkerDrag(self)
    if not self.reminder or self.reminder.trigger ~= "PULL" then return end
    self.isDragging = true
    self.dragTime = self.reminder.at or 0
    GameTooltip:Hide()
    self:SetAlpha(1)
    self:SetScript("OnUpdate", OnDragUpdate)
end

local function StopMarkerDrag(self)
    if not self.isDragging then return end
    self:SetScript("OnUpdate", nil)
    self.isDragging = false
    timeline.dragText:Hide()

    if self.reminder and self.dragTime then
        self.reminder.at = self.dragTime
        local plan = ns.GetActivePlan(O.selectedEncounter)
        if plan then ns.Serialize:Sort(plan.reminders) end
    end
    O:RefreshList()
end

--------------------------------------------------------------------------------
-- Pools
--------------------------------------------------------------------------------

-- Which row the cursor is currently over, clamped to the rows that exist.
local function RowIndexAtCursor()
    local canvas = timeline.gutterCanvas
    local scale = canvas:GetEffectiveScale()
    if not scale or scale == 0 then return nil end
    local top = canvas:GetTop()
    if not top then return nil end

    local _, cursorY = GetCursorPosition()
    local index = math.floor((top - cursorY / scale) / LANE_H) + 1
    return math.max(1, math.min(timeline.laneCount or 1, index))
end

-- While a row is being dragged the others slide out of its way, so the order you
-- are about to commit is the order you can see.
local function OnRowDragUpdate(self)
    local target = RowIndexAtCursor()
    if not target or target == self.dropIndex then return end
    self.dropIndex = target

    local from, n = self.laneIndex, timeline.laneCount or 0
    local slots, k = {}, 0
    for j = 1, n do
        if j ~= from then k = k + 1; slots[k] = j end
    end
    table.insert(slots, math.max(1, math.min(#slots + 1, target)), from)

    for newIndex, oldIndex in ipairs(slots) do
        local row = timeline.rows[oldIndex]
        if row then
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", timeline.gutterCanvas, "TOPLEFT", 0, -((newIndex - 1) * LANE_H))
        end
    end

    timeline.dropLine:ClearAllPoints()
    timeline.dropLine:SetPoint("TOPLEFT", timeline.canvas, "TOPLEFT", 0, -((target - 1) * LANE_H))
    timeline.dropLine:SetPoint("TOPRIGHT", timeline.canvas, "TOPRIGHT", 0, -((target - 1) * LANE_H))
    timeline.dropLine:Show()
end

local function StartRowDrag(self)
    if not self.lane or (timeline.laneCount or 0) < 2 then return end
    self.isDragging = true
    self.dropIndex = nil
    GameTooltip:Hide()
    self.baseLevel = self:GetFrameLevel()
    self:SetFrameLevel(self.baseLevel + 10)
    self:SetAlpha(0.9)
    self:SetScript("OnUpdate", OnRowDragUpdate)
end

local function StopRowDrag(self)
    if not self.isDragging then return end
    self:SetScript("OnUpdate", nil)
    self.isDragging = false
    self:SetAlpha(1)
    if self.baseLevel then self:SetFrameLevel(self.baseLevel) end
    timeline.dropLine:Hide()

    local plan = O.selectedEncounter and ns.GetActivePlan(O.selectedEncounter)
    local order = plan and plan.laneOrder
    local lanes = timeline.lanes
    local from, to = self.laneIndex, self.dropIndex

    -- Splice by key, not by index. The stored order also holds abilities with no
    -- cues at the moment, so a row's position on screen is not its position in
    -- the list, and removing by row number would move the wrong ability.
    if order and lanes and from and to and from ~= to and lanes[to] then
        local moving = self.lane and self.lane.key
        local anchor = lanes[to].key
        if moving and anchor then
            for i = #order, 1, -1 do
                if order[i] == moving then table.remove(order, i) end
            end
            local pos = #order + 1
            for i, key in ipairs(order) do
                if key == anchor then
                    pos = (to > from) and (i + 1) or i
                    break
                end
            end
            table.insert(order, pos, moving)
        end
    end
    O:RefreshList()
end

-- A cue is drawn as a bar spanning the time it is actually on your screen: from
-- the head of its countdown to the end of its hold. That makes overlap visible,
-- which is the thing a healing plan is really about.
local function AcquireBlock(index)
    local b = timeline.blocks[index]
    if not b then
        b = CreateFrame("Button", nil, timeline.canvas)
        b:SetHeight(BLOCK_H)
        b:RegisterForClicks("LeftButtonUp", "RightButtonUp")
        b:RegisterForDrag("LeftButton")

        b.bg = b:CreateTexture(nil, "BACKGROUND")
        b.bg:SetAllPoints()

        -- A brighter cap on the fire moment, so a long countdown still reads as
        -- "it happens HERE" rather than as a vague smear.
        b.cap = b:CreateTexture(nil, "ARTWORK")
        b.cap:SetPoint("TOPRIGHT")
        b.cap:SetPoint("BOTTOMRIGHT")
        b.cap:SetWidth(3)

        b.icon = b:CreateTexture(nil, "ARTWORK")
        b.icon:SetSize(BLOCK_H - 4, BLOCK_H - 4)
        b.icon:SetPoint("LEFT", 2, 0)
        b.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)

        b.text = b:CreateFontString(nil, "OVERLAY")
        b.text:SetFont(FONT, 10, "OUTLINE")
        b.text:SetPoint("LEFT", b.icon, "RIGHT", 3, 0)
        b.text:SetPoint("RIGHT", -4, 0)
        b.text:SetJustifyH("LEFT")

        b:SetScript("OnEnter", MarkerTooltip)
        b:SetScript("OnLeave", function() GameTooltip:Hide() end)
        b:SetScript("OnDragStart", StartMarkerDrag)
        b:SetScript("OnDragStop", StopMarkerDrag)
        b:SetScript("OnClick", function(self, button)
            if self.isDragging then return end
            if button == "RightButton" then
                ShowCueMenu(self, self.reminder)
            else
                self.reminder.enabled = not self.reminder.enabled
                O:RefreshList()
            end
        end)

        timeline.blocks[index] = b
    end
    return b
end

local function AcquireStripe(index)
    local s = timeline.stripes[index]
    if not s then
        s = timeline.canvas:CreateTexture(nil, "BACKGROUND", nil, -7)
        timeline.stripes[index] = s
    end
    return s
end

local function AcquireTick(index)
    local t = timeline.ticks[index]
    if not t then
        t = {}
        -- The line lives with the lanes and scrolls with them; the label lives in
        -- the ruler so it stays pinned to the top when the lanes scroll down.
        t.line = timeline.canvas:CreateTexture(nil, "BACKGROUND", nil, -6)
        t.line:SetWidth(1)
        t.label = timeline.rulerCanvas:CreateFontString(nil, "ARTWORK")
        t.label:SetFont(FONT, 10, "")
        Tint(t.label, "boneDim")
        timeline.ticks[index] = t
    end
    return t
end

local function AcquireLaneRow(index)
    local row = timeline.rows[index]
    if not row then
        row = CreateFrame("Button", nil, timeline.gutterCanvas)
        row:SetHeight(LANE_H)
        row:RegisterForClicks("LeftButtonUp")

        row.swatch = row:CreateTexture(nil, "ARTWORK")
        row.swatch:SetWidth(3)
        row.swatch:SetPoint("TOPRIGHT", -1, -3)
        row.swatch:SetPoint("BOTTOMRIGHT", -1, 3)

        row.icon = row:CreateTexture(nil, "ARTWORK")
        row.icon:SetSize(LANE_H - 10, LANE_H - 10)
        row.icon:SetPoint("RIGHT", row.swatch, "LEFT", -4, 0)
        row.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)

        row.check = row:CreateTexture(nil, "ARTWORK")
        row.check:SetSize(14, 14)
        row.check:SetPoint("LEFT", 2, 0)

        row.text = row:CreateFontString(nil, "OVERLAY")
        row.text:SetFont(FONT, 11, "")
        row.text:SetPoint("LEFT", row.check, "RIGHT", 4, 0)
        row.text:SetPoint("RIGHT", row.icon, "LEFT", -4, 0)
        row.text:SetJustifyH("RIGHT")
        row.text:SetWordWrap(false)

        row:SetScript("OnEnter", LaneTooltip)
        row:SetScript("OnLeave", function() GameTooltip:Hide() end)
        row:RegisterForDrag("LeftButton")
        row:SetScript("OnDragStart", StartRowDrag)
        row:SetScript("OnDragStop", StopRowDrag)
        row:SetScript("OnClick", function(self)
            if self.isDragging then return end
            local lane = self.lane
            if not lane then return end
            local turnOn = not lane.anyEnabled
            for _, r in ipairs(lane.cues) do r.enabled = turnOn end
            O:RefreshList()
        end)

        timeline.rows[index] = row
    end
    return row
end

local function AcquireChip(index)
    local c = timeline.chips[index]
    if not c then
        c = CreateFrame("Button", nil, timeline.chipArea)
        c:SetHeight(22)
        c:RegisterForClicks("LeftButtonUp", "RightButtonUp")
        c.bg = c:CreateTexture(nil, "BACKGROUND")
        c.bg:SetAllPoints()
        c.icon = c:CreateTexture(nil, "ARTWORK")
        c.icon:SetSize(16, 16)
        c.icon:SetPoint("LEFT", 3, 0)
        c.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
        c.text = c:CreateFontString(nil, "OVERLAY")
        c.text:SetFont(FONT, 11, "")
        c.text:SetPoint("LEFT", c.icon, "RIGHT", 4, 0)
        c:SetScript("OnEnter", MarkerTooltip)
        c:SetScript("OnLeave", function() GameTooltip:Hide() end)
        c:SetScript("OnClick", function(self, button)
            if button == "RightButton" then
                ShowCueMenu(self, self.reminder)
            else
                self.reminder.enabled = not self.reminder.enabled
                O:RefreshList()
            end
        end)
        timeline.chips[index] = c
    end
    return c
end

--------------------------------------------------------------------------------
-- Scrolling
--------------------------------------------------------------------------------

-- Three panes share one pair of offsets: the ruler tracks horizontally only, the
-- name gutter vertically only, and the lanes both. Driving them from one place is
-- what keeps a row label attached to its row.
local function SyncScroll()
    if not timeline then return end
    local h = timeline.hScroll or 0
    local v = timeline.vScroll or 0
    timeline.track:SetHorizontalScroll(h)
    timeline.track:SetVerticalScroll(v)
    timeline.ruler:SetHorizontalScroll(h)
    timeline.gutter:SetVerticalScroll(v)
end

local function SetScroll(h, v)
    timeline.hScroll = math.max(0, math.min(timeline.hMax or 0, h or timeline.hScroll or 0))
    timeline.vScroll = math.max(0, math.min(timeline.vMax or 0, v or timeline.vScroll or 0))
    if timeline.hSlider:GetValue() ~= timeline.hScroll then
        timeline.hSlider:SetValue(timeline.hScroll)
    end
    if timeline.vSlider:GetValue() ~= timeline.vScroll then
        timeline.vSlider:SetValue(timeline.vScroll)
    end
    SyncScroll()
end

function O:SetZoom(pps)
    CadenceDB.settings.zoom = math.max(MIN_ZOOM, math.min(MAX_ZOOM, pps))
    O:RefreshList()
end

--------------------------------------------------------------------------------
-- Drawing
--------------------------------------------------------------------------------

-- Draws the timeline. Still called RefreshList because everything else calls it.
function O:RefreshList()
    if not timeline then return end

    for _, b in ipairs(timeline.blocks) do b:Hide() end
    for _, s in ipairs(timeline.stripes) do s:Hide() end
    for _, r in ipairs(timeline.rows) do r:Hide() end
    for _, t in ipairs(timeline.ticks) do t.line:Hide(); t.label:Hide() end
    for _, c in ipairs(timeline.chips) do c:Hide() end

    local reminders = O.selectedEncounter and ns.GetReminders(O.selectedEncounter) or {}

    local timed, anchored = {}, {}
    for _, r in ipairs(reminders) do
        if r.trigger == "BOSSMOD" then anchored[#anchored + 1] = r else timed[#timed + 1] = r end
    end
    table.sort(timed, function(a, b) return (a.at or 0) < (b.at or 0) end)

    timeline.emptyText:SetShown(#reminders == 0)
    if timeline.pendingText then
        timeline.pendingText:SetShown((O.selectedEncounter or 0) < 0)
        timeline.pendingText:SetText(
            "Not pulled yet — cues start working once you engage this boss and Cadence learns its ID.")
    end

    -- Group into rows, then place them in the order this plan remembers.
    local plan = O.selectedEncounter and ns.GetActivePlan(O.selectedEncounter)
    local byKey, discovered = {}, {}
    for _, r in ipairs(timed) do
        local key = LaneKey(r)
        local lane = byKey[key]
        if not lane then
            lane = { key = key, name = ReminderName(r), spellID = r.spellID,
                     cues = {}, anyEnabled = false }
            byKey[key] = lane
            discovered[#discovered + 1] = lane
        end
        lane.cues[#lane.cues + 1] = r
        if r.enabled then lane.anyEnabled = true end
    end
    local lanes = OrderedLanes(plan, byKey, discovered)
    timeline.lanes = lanes
    timeline.laneCount = #lanes

    local pps = Zoom()
    local trackW = timeline.track:GetWidth() or 400
    local trackH = timeline.track:GetHeight() or 200
    local hold = CadenceDB.settings.holdTime or 2

    local maxAt = 60
    for _, r in ipairs(timed) do maxAt = math.max(maxAt, (r.at or 0) + hold) end
    local width = math.max(trackW, TimeToX(maxAt) + PAD)
    local height = math.max(trackH, #lanes * LANE_H + 4)

    timeline.canvas:SetSize(width, height)
    timeline.rulerCanvas:SetSize(width, RULER_H)
    timeline.gutterCanvas:SetSize(GUTTER_W, height)

    -- Row stripes. Alternating, so a long row is still easy to follow across.
    for i = 1, #lanes do
        local s = AcquireStripe(i)
        s:ClearAllPoints()
        s:SetPoint("TOPLEFT", timeline.canvas, "TOPLEFT", 0, -((i - 1) * LANE_H))
        s:SetSize(width, LANE_H)
        local shade = (i % 2 == 0) and 0.16 or 0.10
        s:SetColorTexture(shade, shade * 0.85, shade * 0.9, 0.55)
        s:Show()
    end

    -- Tick spacing grows with zoom so labels never crowd each other.
    local step = 300
    for _, candidate in ipairs({ 5, 10, 15, 30, 60, 120, 300 }) do
        if candidate * pps >= 48 then step = candidate break end
    end

    local tickIndex = 0
    for sec = 0, maxAt + step, step do
        tickIndex = tickIndex + 1
        local t = AcquireTick(tickIndex)
        local x = TimeToX(sec)
        t.line:ClearAllPoints()
        t.line:SetPoint("TOPLEFT", timeline.canvas, "TOPLEFT", x, 0)
        t.line:SetHeight(height)
        t.line:SetColorTexture(ns.COLORS.bone[1], ns.COLORS.bone[2], ns.COLORS.bone[3],
            sec == 0 and 0.22 or 0.08)
        t.line:Show()
        t.label:ClearAllPoints()
        t.label:SetPoint("LEFT", timeline.rulerCanvas, "LEFT", x + 3, 0)
        t.label:SetText(ns.FormatTime(sec))
        t.label:Show()
    end

    -- Name gutter.
    for i, lane in ipairs(lanes) do
        local row = AcquireLaneRow(i)
        row.lane = lane
        row.laneIndex = i
        row.isDragging = false
        row:SetScript("OnUpdate", nil)
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", timeline.gutterCanvas, "TOPLEFT", 0, -((i - 1) * LANE_H))
        row:SetWidth(GUTTER_W)

        row.text:SetText(lane.name)
        Tint(row.text, lane.anyEnabled and "bone" or "boneDim")

        local icon = lane.spellID and ns.GetSpellIcon(lane.spellID)
        row.icon:SetTexture(icon or nil)
        row.icon:SetShown(icon ~= nil)
        row.icon:SetDesaturated(not lane.anyEnabled)

        local lr, lg, lb = LaneColor(i)
        row.swatch:SetColorTexture(lr, lg, lb, lane.anyEnabled and 1 or 0.3)

        row.check:SetTexture(lane.anyEnabled
            and "Interface\\Buttons\\UI-CheckBox-Check"
            or "Interface\\Buttons\\UI-CheckBox-Up")
        row.check:SetAlpha(lane.anyEnabled and 1 or 0.45)
        row:Show()
    end

    -- Cue blocks.
    local blockIndex = 0
    for i, lane in ipairs(lanes) do
        local lr, lg, lb = LaneColor(i)
        local y = -((i - 1) * LANE_H) - (LANE_H - BLOCK_H) / 2

        for _, r in ipairs(lane.cues) do
            blockIndex = blockIndex + 1
            local b = AcquireBlock(blockIndex)
            b.reminder = r
            b.dragY = y
            b.isDragging = false
            b:SetScript("OnUpdate", nil)

            local at = r.at or 0
            local lead = ns.EffectiveLead(r)
            local x0 = TimeToX(at - lead)
            local w = math.max(MIN_BLOCK, TimeToX(at + hold) - x0)

            b:ClearAllPoints()
            b:SetPoint("TOPLEFT", timeline.canvas, "TOPLEFT", x0, y)
            b:SetWidth(w)

            b.bg:SetColorTexture(lr, lg, lb, r.enabled and 0.85 or 0.25)
            b.cap:SetColorTexture(1, 1, 1, r.enabled and 0.55 or 0.15)
            -- The cap marks the exact fire moment. It stays at the true time offset
            -- even when the block has been widened to the readable minimum, so the
            -- bar is the approximate span and the cap is the precise answer. Held
            -- a couple of pixels inside the edges or a 0:00 cue, whose countdown is
            -- zero, would hang its cap half outside its own block.
            local capX = math.min(math.max(TimeToX(at) - x0, 2), w - 2)
            b.cap:ClearAllPoints()
            b.cap:SetPoint("TOP", b, "TOPLEFT", capX, 0)
            b.cap:SetPoint("BOTTOM", b, "BOTTOMLEFT", capX, 0)

            local icon = r.spellID and ns.GetSpellIcon(r.spellID)
            b.icon:SetTexture(icon or nil)
            b.icon:SetShown(icon ~= nil and w > 34)
            b.icon:SetDesaturated(not r.enabled)
            b.text:SetText(w > 64 and ns.FormatTime(at) or "")
            b:SetAlpha(r.enabled and 1 or 0.55)
            b:Show()
        end
    end

    -- Boss-timer cues have no pull time, so they get their own strip.
    local chipX, row = 0, 0
    local chipW = timeline.chipArea:GetWidth() or 400
    for i, r in ipairs(anchored) do
        local c = AcquireChip(i)
        c.reminder = r
        c.text:SetText(ReminderName(r))
        c:SetWidth(c.text:GetStringWidth() + 30)
        local icon = r.spellID and ns.GetSpellIcon(r.spellID)
        c.icon:SetTexture(icon or nil)
        c.icon:SetShown(icon ~= nil)
        c.icon:SetDesaturated(not r.enabled)
        c.bg:SetColorTexture(ns.COLORS.redDim[1], ns.COLORS.redDim[2], ns.COLORS.redDim[3],
            r.enabled and 0.95 or 0.35)
        c:SetAlpha(r.enabled and 1 or 0.5)

        if chipX + c:GetWidth() > chipW then chipX, row = 0, row + 1 end
        c:ClearAllPoints()
        c:SetPoint("TOPLEFT", timeline.chipArea, "TOPLEFT", chipX, -(row * 24))
        chipX = chipX + c:GetWidth() + 4
        c:Show()
    end
    timeline.chipLabel:SetText(#anchored > 0
        and "Anchored to boss timers"
        or "Anchored to boss timers — none yet")

    timeline.hMax = math.max(0, width - trackW)
    timeline.vMax = math.max(0, height - trackH)
    timeline.hSlider:SetMinMaxValues(0, timeline.hMax)
    timeline.vSlider:SetMinMaxValues(0, timeline.vMax)
    timeline.hSlider:SetShown(timeline.hMax > 0)
    timeline.vSlider:SetShown(timeline.vMax > 0)
    SetScroll(timeline.hScroll, timeline.vScroll)
end

--------------------------------------------------------------------------------
-- Construction
--------------------------------------------------------------------------------

-- chipArea and chipLabel are passed in rather than assigned by the caller after
-- the fact: OnSizeChanged is already live by this point and RefreshList indexes
-- both, so the timeline table has to be complete the moment it exists.
local function BuildTimeline(p, anchorTop, anchorRight, anchorBottom, chipArea, chipLabel)
    timeline = {
        blocks = {}, ticks = {}, chips = {}, rows = {}, stripes = {},
        chipArea = chipArea, chipLabel = chipLabel,
        hScroll = 0, vScroll = 0, hMax = 0, vMax = 0,
    }
    O.timelineMarkers, O.timelineChips = timeline.blocks, timeline.chips

    local frame = CreateFrame("Frame", nil, p, "BackdropTemplate")
    frame:SetPoint("TOPLEFT", p, "TOPLEFT", 16, anchorTop)
    frame:SetPoint("RIGHT", anchorRight, "LEFT", -12, 0)
    frame:SetPoint("BOTTOM", anchorBottom, "TOP", 0, 6)
    if frame.SetBackdrop then
        frame:SetBackdrop({
            bgFile = "Interface\\ChatFrame\\ChatFrameBackground",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            tile = true, tileSize = 16, edgeSize = 12,
            insets = { left = 3, right = 3, top = 3, bottom = 3 },
        })
        local r, g, b = unpack(ns.COLORS.black)
        frame:SetBackdropColor(r, g, b, 0.72)
        local br, bg, bb = unpack(ns.COLORS.red)
        frame:SetBackdropBorderColor(br, bg, bb, 1)
    end
    timeline.frame = frame

    -- Name gutter: scrolls vertically with the lanes, never horizontally.
    local gutter = CreateFrame("ScrollFrame", nil, frame)
    gutter:SetPoint("TOPLEFT", frame, "TOPLEFT", 6, -(6 + RULER_H))
    gutter:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 6, 22)
    gutter:SetWidth(GUTTER_W)
    gutter:EnableMouseWheel(true)
    timeline.gutter = gutter

    local gutterCanvas = CreateFrame("Frame", nil, gutter)
    gutterCanvas:SetSize(GUTTER_W, 100)
    gutter:SetScrollChild(gutterCanvas)
    timeline.gutterCanvas = gutterCanvas

    -- A hairline between the names and the lanes, as in a spreadsheet.
    local divider = frame:CreateTexture(nil, "ARTWORK")
    divider:SetPoint("TOPLEFT", gutter, "TOPRIGHT", 1, RULER_H)
    divider:SetPoint("BOTTOMLEFT", gutter, "BOTTOMRIGHT", 1, 0)
    divider:SetWidth(1)
    divider:SetColorTexture(ns.COLORS.gold[1], ns.COLORS.gold[2], ns.COLORS.gold[3], 0.25)

    -- Time ruler: scrolls horizontally with the lanes, never vertically.
    local ruler = CreateFrame("ScrollFrame", nil, frame)
    ruler:SetPoint("TOPLEFT", gutter, "TOPRIGHT", 4, RULER_H)
    ruler:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -20, -6)
    ruler:SetHeight(RULER_H)
    timeline.ruler = ruler

    local rulerCanvas = CreateFrame("Frame", nil, ruler)
    rulerCanvas:SetSize(400, RULER_H)
    ruler:SetScrollChild(rulerCanvas)
    timeline.rulerCanvas = rulerCanvas

    -- The lanes.
    local track = CreateFrame("ScrollFrame", nil, frame)
    track:SetPoint("TOPLEFT", ruler, "BOTTOMLEFT", 0, 0)
    track:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -20, 22)
    track:EnableMouseWheel(true)
    timeline.track = track

    local canvas = CreateFrame("Frame", nil, track)
    canvas:SetSize(400, 180)
    track:SetScrollChild(canvas)
    timeline.canvas = canvas

    timeline.dragText = canvas:CreateFontString(nil, "OVERLAY")
    timeline.dragText:SetFont(FONT, 14, "OUTLINE")
    Tint(timeline.dragText, "goldLight")
    timeline.dragText:Hide()

    -- Shown across the lanes while a row is being dragged, so the landing place is
    -- visible out where the cues are rather than only in the name column.
    timeline.dropLine = canvas:CreateTexture(nil, "OVERLAY")
    timeline.dropLine:SetHeight(LANE_H)
    timeline.dropLine:SetColorTexture(ns.COLORS.goldLight[1], ns.COLORS.goldLight[2],
        ns.COLORS.goldLight[3], 0.18)
    timeline.dropLine:Hide()

    local hSlider = CreateFrame("Slider", nil, frame, "OptionsSliderTemplate")
    hSlider:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 6 + GUTTER_W + 4, 5)
    hSlider:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -20, 5)
    hSlider:SetHeight(14)
    hSlider:SetOrientation("HORIZONTAL")
    hSlider:SetMinMaxValues(0, 0)
    hSlider:SetValue(0)
    hSlider:SetValueStep(1)
    hSlider:SetObeyStepOnDrag(true)
    if hSlider.Low then hSlider.Low:SetText("") end
    if hSlider.High then hSlider.High:SetText("") end
    if hSlider.Text then hSlider.Text:SetText("") end
    hSlider:SetScript("OnValueChanged", function(_, v) SetScroll(v, nil) end)
    timeline.hSlider = hSlider

    local vSlider = CreateFrame("Slider", nil, frame, "OptionsSliderTemplate")
    vSlider:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -(6 + RULER_H))
    vSlider:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -6, 22)
    vSlider:SetWidth(14)
    vSlider:SetOrientation("VERTICAL")
    vSlider:SetMinMaxValues(0, 0)
    vSlider:SetValue(0)
    vSlider:SetValueStep(1)
    vSlider:SetObeyStepOnDrag(true)
    if vSlider.Low then vSlider.Low:SetText("") end
    if vSlider.High then vSlider.High:SetText("") end
    if vSlider.Text then vSlider.Text:SetText("") end
    vSlider:SetScript("OnValueChanged", function(_, v) SetScroll(nil, v) end)
    timeline.vSlider = vSlider

    -- Wheel walks the rows, shift+wheel walks the clock. Rows are what you run out
    -- of first, so they get the bare wheel. The name column answers the wheel too,
    -- because that is where your cursor already is when you are reading the plan.
    local function OnWheel(_, delta)
        if IsShiftKeyDown and IsShiftKeyDown() then
            SetScroll((timeline.hScroll or 0) - delta * 60, nil)
        else
            SetScroll(nil, (timeline.vScroll or 0) - delta * LANE_H)
        end
    end
    track:SetScript("OnMouseWheel", OnWheel)
    gutter:SetScript("OnMouseWheel", OnWheel)

    timeline.emptyText = frame:CreateFontString(nil, "OVERLAY")
    timeline.emptyText:SetFont(FONT, 12, "")
    timeline.emptyText:SetPoint("CENTER")
    Tint(timeline.emptyText, "boneDim")
    timeline.emptyText:SetText("No reminders yet. Add one, or import a timeline.")

    timeline.pendingText = frame:CreateFontString(nil, "OVERLAY")
    timeline.pendingText:SetFont(FONT, 11, "")
    timeline.pendingText:SetPoint("BOTTOM", 0, 28)
    Tint(timeline.pendingText, "gold")
    timeline.pendingText:Hide()

    return frame
end

--------------------------------------------------------------------------------
-- Editor
--------------------------------------------------------------------------------

local EDITOR_W, ROW_GAP = 400, 8

local function EditorRow(parent, labelText)
    local row = CreateFrame("Frame", nil, parent)
    row:SetWidth(EDITOR_W - 40)
    row:SetHeight(42)

    row.label = row:CreateFontString(nil, "ARTWORK")
    row.label:SetFont(FONT, 11, "")
    row.label:SetPoint("TOPLEFT", 0, 0)
    row.label:SetPoint("TOPRIGHT", 0, 0)
    row.label:SetJustifyH("LEFT")
    row.label:SetWordWrap(false)
    row.label:SetText(labelText)

    return row
end

local function LayoutEditor(e)
    local y = -10
    for _, row in ipairs(e.rows) do
        if row:IsShown() then
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", e.body, "TOPLEFT", 20, y)
            y = y - row:GetHeight() - ROW_GAP
        end
    end
    e:SetHeight(-y + 34 + 46)
end

local function BuildEditor()
    local e = CreateFrame("Frame", "CadenceEditor", UIParent, "BasicFrameTemplateWithInset")
    e:SetWidth(EDITOR_W)
    e:SetHeight(440)
    e:SetPoint("CENTER")
    e:SetFrameStrata("FULLSCREEN_DIALOG")
    e:SetMovable(true)
    e:EnableMouse(true)
    e:RegisterForDrag("LeftButton")
    e:SetScript("OnDragStart", e.StartMoving)
    e:SetScript("OnDragStop", e.StopMovingOrSizing)
    e:SetClampedToScreen(true)
    e:Hide()
    tinsert(UISpecialFrames, "CadenceEditor")
    if e.TitleText then
        e.TitleText:SetText("Reminder")
        Tint(e.TitleText, "goldLight")
    end
    ns.TintFrame(e)

    local body = CreateFrame("Frame", nil, e)
    body:SetPoint("TOPLEFT", 0, -26)
    body:SetPoint("BOTTOMRIGHT", 0, 40)
    e.body = body

    -- Trigger
    local rTrigger = EditorRow(body, "Trigger")
    rTrigger:SetHeight(46)
    e.triggerDD = Dropdown(rTrigger, 240, function(_, root)
        root:CreateButton("At a time after the pull", function()
            e.current.trigger = "PULL"; O:OpenEditor(e.current)
        end)
        root:CreateButton("Anchored to a boss timer", function()
            e.current.trigger = "BOSSMOD"; O:OpenEditor(e.current)
        end)
    end, "Trigger")
    e.triggerDD:SetPoint("TOPLEFT", rTrigger, "TOPLEFT", -4, -16)

    -- Pull timing
    local rTime = EditorRow(body, "Time into the fight (m:ss)")
    e.time = EditBox(rTime, 100)
    e.time:SetPoint("TOPLEFT", rTime, "TOPLEFT", 6, -18)

    -- Boss ability, learned from past pulls
    local rMatch = EditorRow(body, "Boss ability")
    rMatch:SetHeight(76)
    e.match = EditBox(rMatch, EDITOR_W - 86)
    e.match:SetPoint("TOPLEFT", rMatch, "TOPLEFT", 6, -18)
    e.abilityDD = Dropdown(rMatch, 240, function(_, root)
        Scrollable(root)
        local bars = ns.GetKnownBars(O.selectedEncounter)
        if #bars == 0 then
            root:CreateButton("Nothing learned yet — pull the boss once", function() end)
            return
        end
        for _, bar in ipairs(bars) do
            local text = bar.duration
                and ("%s  (%ds bar)"):format(bar.text, math.floor(bar.duration))
                or bar.text
            root:CreateButton(text, function()
                e.match:SetText(bar.text)
                e.abilityDD:SetDefaultText(bar.text)
            end)
        end
    end, "Pick an ability")
    e.abilityDD:SetPoint("TOPLEFT", rMatch, "TOPLEFT", 0, -44)

    local rOffset = EditorRow(body, "Seconds before (-) or after (+) that cast")
    e.offset = EditBox(rOffset, 80)
    e.offset:SetPoint("TOPLEFT", rOffset, "TOPLEFT", 6, -18)

    local rOcc = EditorRow(body, "Which cast (0 = every one)")
    e.occurrence = EditBox(rOcc, 80)
    e.occurrence:SetPoint("TOPLEFT", rOcc, "TOPLEFT", 6, -18)

    -- Spell
    local rSpell = EditorRow(body, "Spell name or ID")
    rSpell:SetHeight(76)
    e.spell = EditBox(rSpell, EDITOR_W - 86)
    e.spell:SetPoint("TOPLEFT", rSpell, "TOPLEFT", 6, -18)
    e.pickDD = Dropdown(rSpell, 240, function(_, root)
        local mine, specName = ns.GetSpecCooldowns()
        local function pick(cd)
            return function() e.spell:SetText(cd.known and cd.name or tostring(cd.spellID)) end
        end

        -- Your own spells sit at the top level; every other spec is one submenu
        -- deep rather than two, which keeps the menus from stacking over
        -- each other on the way down.
        for _, cd in ipairs(mine) do
            root:CreateButton(cd.name, pick(cd))
        end
        if #mine > 0 then pcall(function() root:CreateDivider() end) end

        for _, group in ipairs(ns.GetAllCooldownGroups()) do
            if group.spec ~= specName and #group.spells > 0 then
                local sub = root:CreateButton(group.spec)
                for _, cd in ipairs(group.spells) do
                    sub:CreateButton(cd.name, pick(cd))
                end
                Scrollable(sub)   -- leaf list: safe to scroll
            end
        end
    end, "Pick a spell")
    e.pickDD:SetPoint("TOPLEFT", rSpell, "TOPLEFT", 0, -44)

    local rLead = EditorRow(body, "Countdown in seconds")
    e.leadRow = rLead
    e.lead = EditBox(rLead, 80)
    e.lead:SetPoint("TOPLEFT", rLead, "TOPLEFT", 6, -18)

    local rSound = EditorRow(body, "Sound")
    rSound:SetHeight(46)

    e.mute = CheckBox(rSound, "Silent")
    e.mute:SetPoint("TOPLEFT", rSound, "TOPLEFT", 0, -18)

    -- Anchor off the label of the previous checkbox so the gap is consistent
    -- regardless of font rendering, rather than off the 24px button frame which
    -- ignores however wide the text turned out to be.
    e.blip = CheckBox(rSound, "Default blip")
    e.blip:SetPoint("LEFT", e.mute.text, "RIGHT", 16, 0)

    local soundTest = Button(rSound, "Test", 60, 20)
    soundTest:SetPoint("LEFT", e.blip.text, "RIGHT", 16, 0)
    soundTest:SetScript("OnClick", function()
        if e.mute:GetChecked() then return end
        -- Build a temporary reminder reflecting the current checkbox state so the
        -- test hears exactly what the cue will sound like after Save.
        local tmp = {
            label   = e.spell:GetText() or "",
            spellID = e.current and e.current.spellID,
            sound   = "",
            mute    = false,
            blip    = e.blip:GetChecked() and true or false,
        }
        ns.Display:PlayCueSound(tmp)
    end)

    local function RefreshSoundRow()
        local silent = e.mute:GetChecked()
        e.blip:SetEnabled(not silent)
        e.blip:SetAlpha(silent and 0.4 or 1)
        soundTest:SetEnabled(not silent)
    end
    e.mute:SetScript("OnClick", RefreshSoundRow)
    e.blip:SetScript("OnClick", RefreshSoundRow)
    e.RefreshSoundField = RefreshSoundRow    -- kept for any external callers

    local rNote = EditorRow(body, "Note (second line on the cue)")
    e.note = EditBox(rNote, EDITOR_W - 86)
    e.note:SetPoint("TOPLEFT", rNote, "TOPLEFT", 6, -18)

    e.rows = { rTrigger, rTime, rMatch, rOffset, rOcc, rSpell, rLead, rSound, rNote }
    e.rowsByKey = { time = rTime, match = rMatch, offset = rOffset, occurrence = rOcc }

    e.save = Button(e, "Save", 110)
    e.save:SetPoint("BOTTOMLEFT", 16, 12)
    e.cancel = Button(e, "Cancel", 110)
    e.cancel:SetPoint("LEFT", e.save, "RIGHT", 8, 0)

    e.save:SetScript("OnClick", function()
        local r = e.current
        r.trigger = r.trigger or "PULL"

        if r.trigger == "PULL" then
            r.at = ns.ParseTime(e.time:GetText()) or 0
        else
            r.match = e.match:GetText() or ""
            r.offset = tonumber(e.offset:GetText()) or 0
            r.occurrence = tonumber(e.occurrence:GetText()) or 1
        end

        r.spellID, r.label = ns.ParseSpellInput(e.spell:GetText())
        r.lead  = tonumber(e.lead:GetText())
        r.sound = ""
        r.mute  = e.mute:GetChecked() and true or false
        r.blip  = e.blip:GetChecked() and true or false
        r.note  = e.note:GetText() or ""

        local plan = ns.GetActivePlan(O.selectedEncounter, true)
        local exists = false
        for _, v in ipairs(plan.reminders) do if v.id == r.id then exists = true break end end
        if not exists then plan.reminders[#plan.reminders + 1] = r end

        -- Editing a time reorders the plan. The timeline sorts its own copy so it
        -- looked right either way, but the stored order is what gets exported.
        ns.Serialize:Sort(plan.reminders)

        ns.RebuildSpellIndex()
        e:Hide()
        O:RefreshList()
    end)

    e.cancel:SetScript("OnClick", function() e:Hide() end)

    return e
end

function O:OpenEditor(reminder)
    if not O.selectedEncounter then
        ns.Print("Pick a boss first.")
        return
    end
    if not editor then editor = BuildEditor(); O.editor = editor end

    editor.current = reminder or {
        id = ns.NewID(), enabled = true, trigger = "PULL",
        at = 0, offset = -3, occurrence = 1, match = "", label = "", note = "",
    }
    local r = editor.current
    local isPull = r.trigger ~= "BOSSMOD"

    editor.triggerDD:SetDefaultText(isPull and "At a time after the pull" or "Anchored to a boss timer")

    editor.rowsByKey.time:SetShown(isPull)
    editor.rowsByKey.match:SetShown(not isPull)
    editor.rowsByKey.offset:SetShown(not isPull)
    editor.rowsByKey.occurrence:SetShown(not isPull)

    editor.time:SetText(ns.FormatTime(r.at or 0))
    editor.match:SetText(r.match or "")
    editor.abilityDD:SetDefaultText((r.match and r.match ~= "") and r.match or "Pick an ability")
    editor.offset:SetText(tostring(r.offset or -3))
    editor.occurrence:SetText(tostring(r.occurrence or 1))
    editor.spell:SetText((r.label and r.label ~= "") and r.label or ns.SpellLabel(r.spellID) or "")
    local defaultLead = CadenceDB.settings.lead or 5
    if isPull and ns.EffectiveLead(r) <= 0 then
        editor.leadRow.label:SetText("Countdown in seconds — at 0:00 there is no run-up, so it fires on the pull")
    elseif isPull and (r.at or 0) < (r.lead or defaultLead) then
        editor.leadRow.label:SetText(
            ("Countdown in seconds (capped at %s here — the fight has not started before that)")
            :format(ns.FormatTime(r.at or 0)))
    else
        editor.leadRow.label:SetText(
            ("Countdown in seconds (blank defaults to %s seconds)"):format(defaultLead))
    end
    editor.lead:SetText(r.lead and tostring(r.lead) or "")
    editor.mute:SetChecked(r.mute and true or false)
    editor.blip:SetChecked(r.blip and true or false)
    if editor.RefreshSoundField then editor.RefreshSoundField() end

    editor.note:SetText(r.note or "")

    LayoutEditor(editor)
    editor:Show()
end

--------------------------------------------------------------------------------
-- Import / export dialog
--------------------------------------------------------------------------------

local function BuildDialog()
    local d = CreateFrame("Frame", "CadenceIODialog", UIParent, "BasicFrameTemplateWithInset")
    d:SetSize(560, 440)
    d:SetPoint("CENTER")
    d:SetFrameStrata("FULLSCREEN_DIALOG")
    d:SetMovable(true)
    d:EnableMouse(true)
    d:RegisterForDrag("LeftButton")
    d:SetScript("OnDragStart", d.StartMoving)
    d:SetScript("OnDragStop", d.StopMovingOrSizing)
    d:Hide()
    tinsert(UISpecialFrames, "CadenceIODialog")
    if d.TitleText then
        d.TitleText:SetText("Cadence — share and import")
        Tint(d.TitleText, "goldLight")
    end
    ns.TintFrame(d)

    local hint = d:CreateFontString(nil, "ARTWORK")
    hint:SetFont(FONT, 11, "")
    hint:SetPoint("TOPLEFT", 14, -32)
    hint:SetPoint("TOPRIGHT", -14, -32)
    hint:SetJustifyH("LEFT")
    hint:SetText("Click the box, then Ctrl+V to paste. Takes a Cadence string, a plan export, "
        .. "or any list with times in it. Import reads whichever it finds.")

    local box = CreateFrame("Frame", nil, d, "BackdropTemplate")
    box:SetPoint("TOPLEFT", 14, -70)
    box:SetPoint("BOTTOMRIGHT", -14, 84)
    if box.SetBackdrop then
        box:SetBackdrop({
            bgFile = "Interface\\ChatFrame\\ChatFrameBackground",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            tile = true, tileSize = 16, edgeSize = 14,
            insets = { left = 4, right = 4, top = 4, bottom = 4 },
        })
        box:SetBackdropColor(ns.COLORS.black[1], ns.COLORS.black[2], ns.COLORS.black[3], 0.7)
        box:SetBackdropBorderColor(ns.COLORS.red[1], ns.COLORS.red[2], ns.COLORS.red[3], 1)
    end
    box:EnableMouse(true)

    local scroll = CreateFrame("ScrollFrame", nil, box, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", 8, -8)
    scroll:SetPoint("BOTTOMRIGHT", -28, 8)

    local edit = CreateFrame("EditBox", nil, scroll)
    edit:SetMultiLine(true)
    edit:SetWidth(480)
    edit:SetHeight(700)
    edit:SetAutoFocus(false)
    edit:SetFontObject("ChatFontNormal")
    edit:SetTextInsets(2, 2, 2, 2)
    edit:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    scroll:SetScrollChild(edit)
    d.edit = edit

    local placeholder = box:CreateFontString(nil, "OVERLAY")
    placeholder:SetFont(FONT, 12, "")
    placeholder:SetPoint("TOPLEFT", 12, -12)
    Tint(placeholder, "boneDim")
    placeholder:SetText("Paste here (Ctrl+V)")

    local function UpdatePlaceholder()
        placeholder:SetShown((edit:GetText() or "") == "")
    end
    edit:SetScript("OnTextChanged", UpdatePlaceholder)

    box:SetScript("OnMouseDown", function() edit:SetFocus() end)
    scroll:EnableMouse(true)
    scroll:SetScript("OnMouseDown", function() edit:SetFocus() end)

    local status = d:CreateFontString(nil, "ARTWORK")
    status:SetFont(FONT, 11, "")
    status:SetPoint("BOTTOMLEFT", 16, 52)
    status:SetPoint("BOTTOMRIGHT", -16, 52)
    status:SetJustifyH("LEFT")
    Tint(status, "bone")
    d.status = status

    local replace = CheckBox(d, "Replace what's already there")
    replace:SetPoint("BOTTOMLEFT", 14, 14)
    d.replace = replace

    local importBtn = Button(d, "Import", 100)
    importBtn:SetPoint("BOTTOMRIGHT", -14, 14)

    local exportBtn = Button(d, "Export this boss", 130)
    exportBtn:SetPoint("RIGHT", importBtn, "LEFT", -6, 0)

    exportBtn:SetScript("OnClick", function()
        local str, err = ns.Serialize:Export(O.selectedEncounter)
        if not str then status:SetText(err) return end
        local plan = ns.GetActivePlan(O.selectedEncounter)
        status:SetText(("Exported %s."):format(plan and plan.name or "the plan"))
        edit:SetText(str)
        UpdatePlaceholder()
        edit:SetFocus()
        edit:HighlightText()
        status:SetText("Copied into the box — Ctrl+C to take it.")
    end)

    importBtn:SetScript("OnClick", function()
        local text = edit:GetText() or ""
        local mode = replace:GetChecked() and "replace" or "append"
        local id, msg = ns.Serialize:ImportAny(text, O.selectedEncounter, mode)
        ns.Print(msg)
        status:SetText(msg)
        if id then
            SelectEncounter(id)
            edit:SetText("")
            UpdatePlaceholder()
            d:Hide()
        end
    end)

    d:SetScript("OnShow", function()
        status:SetText("")
        UpdatePlaceholder()
        edit:SetFocus()
    end)

    return d
end

function O:OpenDialog()
    if not dialog then dialog = BuildDialog() end
    dialog:Show()
    return dialog
end

--------------------------------------------------------------------------------
-- Panel
--------------------------------------------------------------------------------

local function BuildPanel(p)
    -- Header
    local intro = p:CreateFontString(nil, "ARTWORK")
    intro:SetFont(FONT, 12, "")
    intro:SetPoint("TOPLEFT", 16, -14)
    intro:SetPoint("TOPRIGHT", -16, -14)
    intro:SetJustifyH("LEFT")
    intro:SetText("Timed reminders for your healing cooldowns. Cues are local: nothing is announced to your group.")
    Tint(intro, "bone")

    -- Bottom row, built first so everything above can anchor to it
    local ioBtn = Button(p, "Share or import", 130)
    ioBtn:SetPoint("BOTTOMLEFT", 16, 12)
    ioBtn:SetScript("OnClick", function() O:OpenDialog() end)

    local testBtn = Button(p, "Test run", 90)
    testBtn:SetPoint("LEFT", ioBtn, "RIGHT", 6, 0)
    testBtn:SetScript("OnClick", function()
        if ns.Schedule:IsTesting() then
            ns.Schedule:Stop()
            ns.Print("Test stopped.")
        else
            ns.Schedule:TestRun(O.selectedEncounter)
        end
        O:RefreshTestButton()
    end)
    p.testBtn = testBtn

    local help = p:CreateFontString(nil, "ARTWORK")
    help:SetFont(FONT, 11, "")
    help:SetPoint("BOTTOMLEFT", 16, 42)
    help:SetJustifyH("LEFT")
    Tint(help, "boneDim")
    help:SetText("Drag a cue to move it. Left-click toggles it. Right-click to edit, duplicate or delete. "
        .. "Right-click a boss in the list to remove it.")

    -- Alerts column, pinned to the right edge so it survives resizing. It scrolls,
    -- so shrinking the window hides none of the settings.
    local rightScroll = CreateFrame("ScrollFrame", nil, p)
    rightScroll:SetPoint("TOPRIGHT", -16, -46)
    rightScroll:SetPoint("BOTTOMRIGHT", -16, 70)
    rightScroll:SetWidth(RIGHT_W)
    rightScroll:EnableMouseWheel(true)

    local right = CreateFrame("Frame", nil, rightScroll)
    right:SetSize(RIGHT_W, 410)
    rightScroll:SetScrollChild(right)

    rightScroll:SetScript("OnMouseWheel", function(self, delta)
        local maxScroll = math.max(0, right:GetHeight() - self:GetHeight())
        self:SetVerticalScroll(math.max(0, math.min(maxScroll, self:GetVerticalScroll() - delta * 30)))
    end)

    -- Boss-timer chip strip
    local chipArea = CreateFrame("Frame", nil, p)
    chipArea:SetPoint("BOTTOMLEFT", 16, 66)
    chipArea:SetPoint("RIGHT", rightScroll, "LEFT", -12, 0)
    chipArea:SetHeight(48)

    local chipLabel = p:CreateFontString(nil, "ARTWORK")
    chipLabel:SetFont(FONT, 11, "")
    chipLabel:SetPoint("BOTTOMLEFT", chipArea, "TOPLEFT", 0, 4)
    Tint(chipLabel, "boneDim")
    chipLabel:SetText("Anchored to boss timers")

    -- Boss row
    Label(p, "Boss", 12, 16, -46)
    -- Right-clicking an entry removes that boss. The menu API has no right-click
    -- callback, so the behaviour is attached to the button once the menu builds it.
    local function RemoveBoss(entry)
        local ok, why = ns.ForgetEncounter(entry.id)
        if not ok then
            ns.Print(why or "Could not remove that boss.")
            return
        end
        if O.selectedEncounter == entry.id then O.selectedEncounter = nil end
        RefreshHeader(); O:RefreshList()
        ns.Print("Removed %s from the list.", entry.name)
    end

    local function AttachRightClickRemove(button, entry)
        if not button or not button.AddInitializer then return false end
        local ok = pcall(function()
            button:AddInitializer(function(frame)
                if not frame or not frame.RegisterForClicks then return end
                frame:RegisterForClicks("LeftButtonUp", "RightButtonUp")
                if frame.cadenceHooked then return end
                frame.cadenceHooked = true
                frame:HookScript("OnClick", function(_, mouseButton)
                    if mouseButton == "RightButton" then RemoveBoss(entry) end
                end)
            end)
        end)
        return ok
    end

    p.bossDropdown = Dropdown(p, 240, function(_, root)
        local list = ns.GetKnownEncounters()
        if #list == 0 then root:CreateButton("No bosses recorded yet", function() end) end

        local attached = true
        local currentRaid
        for _, e in ipairs(list) do
            local raid = e.raid or "Other bosses"
            if raid ~= currentRaid then
                currentRaid = raid
                pcall(function() root:CreateTitle(raid) end)
            end
            -- A boss you have never pulled is marked, because its cues cannot fire
            -- until the real encounter ID is learned.
            local label = e.name
            if e.plans and e.plans > 0 then label = ("%s  (%d)"):format(label, e.plans) end
            if e.pending then label = label .. "  ·  not pulled yet" end

            local entry = e
            local button = root:CreateButton(label, function() SelectEncounter(entry.id) end)
            if not AttachRightClickRemove(button, entry) then attached = false end
        end

        -- Fallback for clients where the initializer hook is unavailable. This is
        -- also why the scroll decision waits until here: a scrolling menu draws its
        -- submenus over the top of itself, so the moment this branch adds one the
        -- root has to stay unscrolled.
        if not attached and #list > 0 then
            pcall(function() root:CreateDivider() end)
            local sub = root:CreateButton("Remove a boss")
            for _, e in ipairs(list) do
                local entry = e
                sub:CreateButton(entry.name, function() RemoveBoss(entry) end)
            end
            Scrollable(sub)
        else
            Scrollable(root)
        end
    end, "Choose a boss")
    p.bossDropdown:SetPoint("TOPLEFT", 16, -64)
    O.bossDropdown = p.bossDropdown          -- exposed for tests

    -- Zoom, pinned to the right of the timeline area
    local zoomIn = Button(p, "+", 26, 20)
    zoomIn:SetPoint("TOPRIGHT", rightScroll, "TOPLEFT", -12, -18)
    zoomIn:SetScript("OnClick", function() O:SetZoom(Zoom() * 1.5) end)

    local zoomOut = Button(p, "-", 26, 20)
    zoomOut:SetPoint("RIGHT", zoomIn, "LEFT", 4, 0)
    zoomOut:SetScript("OnClick", function() O:SetZoom(Zoom() / 1.5) end)

    local zoomLabel = p:CreateFontString(nil, "ARTWORK")
    zoomLabel:SetFont(FONT, 11, "")
    zoomLabel:SetPoint("RIGHT", zoomOut, "LEFT", -6, 0)
    Tint(zoomLabel, "boneDim")
    zoomLabel:SetText("Zoom")

    Label(p, "Plan", 12, 262, -46)
    p.planDropdown = Dropdown(p, 200, function(_, root)
        Scrollable(root)
        if not O.selectedEncounter then
            root:CreateButton("Pick a boss first", function() end)
            return
        end
        for _, plan in ipairs(ns.GetPlans(O.selectedEncounter)) do
            local label = plan.name
            if plan.difficultyName and plan.difficultyName ~= "" then
                label = ("%s  [%s]"):format(plan.name, plan.difficultyName)
            end
            root:CreateButton(("%s  (%d)"):format(label, #plan.reminders), function()
                SelectPlan(plan.id)
            end)
        end
    end, "No plan")
    p.planDropdown:SetPoint("TOPLEFT", 262, -64)

    local addBtn = Button(p, "Add reminder", 120)
    addBtn:SetScript("OnClick", function() O:OpenEditor(nil) end)

    local planActions = Button(p, "Plans", 70)
    planActions:SetPoint("LEFT", p.planDropdown, "RIGHT", 6, 0)
    addBtn:SetPoint("LEFT", planActions, "RIGHT", 6, 0)
    planActions:SetScript("OnClick", function(self)
        if not O.selectedEncounter then
            ns.Print("Pick a boss first.")
            return
        end
        if not (MenuUtil and MenuUtil.CreateContextMenu) then
            AskForName("Name for the new plan", "", function(name)
                if name ~= "" then ns.AddPlan(O.selectedEncounter, name); RefreshHeader(); O:RefreshList() end
            end)
            return
        end
        MenuUtil.CreateContextMenu(self, function(_, root)
            local active = ns.GetActivePlan(O.selectedEncounter)
            pcall(function() root:CreateTitle(active and active.name or "Plans") end)

            root:CreateButton("New plan…", function()
                AskForName("Name for the new plan", "", function(name)
                    if name == "" then return end
                    ns.AddPlan(O.selectedEncounter, name)
                    RefreshHeader(); O:RefreshList()
                end)
            end)

            root:CreateButton("Rename…", function()
                if not active then return end
                AskForName("New name for this plan", active.name, function(name)
                    if name == "" then return end
                    local ok, err = ns.RenamePlan(O.selectedEncounter, active.id, name)
                    if not ok then ns.Print(err or "Could not rename that plan.") end
                    RefreshHeader(); O:RefreshList()
                end)
            end)

            root:CreateButton("Duplicate", function()
                if not active then return end
                ns.DuplicatePlan(O.selectedEncounter, active.id)
                RefreshHeader(); O:RefreshList()
            end)

            local diff = Scrollable(root:CreateButton("Use for difficulty"))
            for _, name in ipairs({ "Normal", "Heroic", "Mythic", "Raid Finder", "none" }) do
                diff:CreateButton(name, function()
                    if not active then return end
                    active.difficultyName = (name ~= "none") and name or nil
                    RefreshHeader()
                end)
            end

            pcall(function() root:CreateDivider() end)
            root:CreateButton("Forget this boss", function()
                local ok, why = ns.ForgetEncounter(O.selectedEncounter)
                if not ok then
                    ns.Print(why or "Could not remove that boss.")
                    return
                end
                O.selectedEncounter = nil
                RefreshHeader(); O:RefreshList()
                ns.Print("Removed from the list.")
            end)
            root:CreateButton("Delete", function()
                if not active then return end
                local _, what = ns.DeletePlan(O.selectedEncounter, active.id)
                ns.Print(what == "emptied"
                    and "That was the only plan, so its reminders were cleared."
                    or "Plan deleted.")
                RefreshHeader(); O:RefreshList()
            end)
        end)
    end)

    BuildTimeline(p, -96, rightScroll, chipLabel, chipArea, chipLabel)

    -- Alerts column contents
    local aTitle = right:CreateFontString(nil, "ARTWORK")
    aTitle:SetFont(FONT, 14, "")
    aTitle:SetPoint("TOPLEFT", 0, -46)
    aTitle:SetText("Alerts")
    Tint(aTitle, "gold")

    local lockBtn = Button(right, "Move the anchor", RIGHT_W - 10)
    lockBtn:SetPoint("TOPLEFT", 0, -68)
    lockBtn:SetScript("OnClick", function()
        CadenceDB.settings.locked = not CadenceDB.settings.locked
        ns.Display:UpdateAnchor()
        lockBtn:SetText(CadenceDB.settings.locked and "Move the anchor" or "Lock the anchor")
    end)

    Label(right, "Default countdown (seconds)", 11, 0, -100)
    local lead = EditBox(right, 50)
    lead:SetPoint("TOPLEFT", 6, -118)
    lead:SetScript("OnEditFocusLost", function(self)
        local v = tonumber(self:GetText())
        if v and v >= 0 then CadenceDB.settings.lead = v else self:SetText(CadenceDB.settings.lead) end
    end)
    p.lead = lead

    local sound = CheckBox(right, "Play a sound on cues")
    sound:SetPoint("TOPLEFT", -4, -142)
    sound:SetScript("OnClick", function(self)
        CadenceDB.settings.sound = self:GetChecked() and true or false
    end)
    p.sound = sound

    local raidOnly = CheckBox(right, "Raid groups only")
    raidOnly:SetPoint("TOPLEFT", -4, -170)
    raidOnly:SetScript("OnClick", function(self) CadenceDB.settings.raidOnly = self:GetChecked() and true or false end)
    p.raidOnly = raidOnly

    Label(right, "Cue scale", 11, 0, -202)
    local scale = EditBox(right, 50)
    scale:SetPoint("TOPLEFT", 6, -220)
    scale:SetScript("OnEditFocusLost", function(self)
        local v = tonumber(self:GetText())
        if v and v >= 0.5 and v <= 3 then
            CadenceDB.settings.scale = v
            ns.Display:UpdateAnchor()
            ns.Display:Restack()
        else
            self:SetText(tostring(CadenceDB.settings.scale))
        end
    end)
    p.scale = scale

    local qTitle = right:CreateFontString(nil, "ARTWORK")
    qTitle:SetFont(FONT, 14, "")
    qTitle:SetPoint("TOPLEFT", 0, -256)
    qTitle:SetText("Upcoming list")
    Tint(qTitle, "gold")

    local queueOn = CheckBox(right, "Show it during fights")
    queueOn:SetPoint("TOPLEFT", -4, -278)
    queueOn:SetScript("OnClick", function(self)
        CadenceDB.settings.queue.enabled = self:GetChecked() and true or false
        if not CadenceDB.settings.queue.enabled then ns.Queue:Clear() end
    end)
    p.queueOn = queueOn

    local queueMove = Button(right, "Preview and place it", RIGHT_W - 10)
    queueMove:SetPoint("TOPLEFT", 0, -306)
    queueMove:SetScript("OnClick", function() ns.Queue:TogglePreview() end)

    Label(right, "Rows to show", 11, 0, -336)
    local queueRows = EditBox(right, 50)
    queueRows:SetPoint("TOPLEFT", 6, -354)
    queueRows:SetScript("OnEditFocusLost", function(self)
        local v = tonumber(self:GetText())
        if v and v >= 1 and v <= 20 then
            CadenceDB.settings.queue.maxRows = math.floor(v)
            ns.Queue:Refresh()
        else
            self:SetText(tostring(CadenceDB.settings.queue.maxRows))
        end
    end)
    p.queueRows = queueRows

    return p
end

-- The test button doubles as a stop button while a test is playing.
function O:RefreshTestButton()
    if panel and panel.testBtn then
        panel.testBtn:SetText(ns.Schedule:IsTesting() and "Stop test" or "Test run")
    end
end

function O:RefreshSettings()
    if not panel then return end
    local s = CadenceDB.settings

    if panel.lead then panel.lead:SetText(tostring(s.lead)) end
    if panel.sound then panel.sound:SetChecked(s.sound) end
    if panel.raidOnly then panel.raidOnly:SetChecked(s.raidOnly) end
    if panel.scale then panel.scale:SetText(tostring(s.scale)) end
    if panel.queueOn then panel.queueOn:SetChecked(s.queue.enabled) end
    if panel.queueRows then panel.queueRows:SetText(tostring(s.queue.maxRows)) end
    RefreshHeader()

    O:RefreshTestButton()
    O:RefreshList()
end

--------------------------------------------------------------------------------
-- The window
--------------------------------------------------------------------------------

local function BuildMainWindow()
    local w = CreateFrame("Frame", "CadenceFrame", UIParent, "BasicFrameTemplateWithInset")
    local saved = CadenceDB.settings.window or {}
    w:SetSize(saved.w or 880, saved.h or 600)
    w:SetPoint(saved.point or "CENTER", UIParent, saved.relPoint or "CENTER", saved.x or 0, saved.y or 0)
    w:SetFrameStrata("HIGH")
    w:SetMovable(true)
    w:SetResizable(true)
    w:EnableMouse(true)
    w:RegisterForDrag("LeftButton")
    w:SetClampedToScreen(true)
    w:Hide()

    if w.SetResizeBounds then
        w:SetResizeBounds(MIN_W, MIN_H)
    elseif w.SetMinResize then
        w:SetMinResize(MIN_W, MIN_H)
    end

    local function SavePlacement()
        local point, _, relPoint, x, y = w:GetPoint()
        CadenceDB.settings.window = {
            point = point, relPoint = relPoint, x = x, y = y,
            w = w:GetWidth(), h = w:GetHeight(),
        }
    end

    w:SetScript("OnDragStart", w.StartMoving)
    w:SetScript("OnDragStop", function(self) self:StopMovingOrSizing(); SavePlacement() end)

    if w.TitleText then
        w.TitleText:SetText("Cadence")
        Tint(w.TitleText, "goldLight")
    end
    ns.TintFrame(w)
    tinsert(UISpecialFrames, "CadenceFrame")

    local content = CreateFrame("Frame", nil, w)
    content:SetPoint("TOPLEFT", 6, -26)
    content:SetPoint("BOTTOMRIGHT", -6, 6)

    -- Resize grip
    local grip = CreateFrame("Button", nil, w)
    grip:SetSize(16, 16)
    grip:SetPoint("BOTTOMRIGHT", -5, 5)
    grip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
    grip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
    grip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
    grip:SetScript("OnMouseDown", function() w:StartSizing("BOTTOMRIGHT") end)
    grip:SetScript("OnMouseUp", function()
        w:StopMovingOrSizing()
        SavePlacement()
        O:RefreshList()
    end)

    -- Redrawing on every size change keeps the timeline honest while dragging.
    w:SetScript("OnSizeChanged", function()
        if timeline then O:RefreshList() end
    end)

    w:SetScript("OnShow", function() O:RefreshSettings() end)

    panel = BuildPanel(content)
    O.panelRef = panel     -- exposed for tests
    return w
end

function O:Init()
    local ok, err = pcall(function() main = BuildMainWindow() end)
    if not ok then
        ns.Print("The panel failed to build: %s", tostring(err))
        ns.Print("Cues still work. /cadence test and /cadence lock are unaffected.")
        return
    end

    pcall(function()
        if not (Settings and Settings.RegisterCanvasLayoutCategory) then return end
        local shim = CreateFrame("Frame")
        shim.name = "Cadence"
        local b = Button(shim, "Open Cadence", 160, 26)
        b:SetPoint("TOPLEFT", 16, -16)
        b:SetScript("OnClick", function() O:Open() end)
        local category = Settings.RegisterCanvasLayoutCategory(shim, "Cadence")
        Settings.RegisterAddOnCategory(category)
        O.category = category
    end)
end

function O:Open()
    if not main then
        ns.Print("The panel is not available. Run /cadence debug.")
        return
    end
    main:SetShown(not main:IsShown())
end

function O:Debug()
    ns.Print("version %s", ns.VERSION)
    ns.Print("window built: %s", tostring(main ~= nil))
    ns.Print("settings shortcut: %s", tostring(O.category ~= nil))
    ns.Print("modern dropdowns: %s", tostring(MenuUtil ~= nil))
    ns.Print("boss selected: %s", tostring(O.selectedEncounter))
    local n = 0
    for _ in pairs(CadenceDB.encounters) do n = n + 1 end
    ns.Print("bosses stored: %d", n)
    ns.Print("plans for this boss: %d, active: %s",
        #ns.GetPlans(O.selectedEncounter), tostring(CurrentPlanName()))
    ns.Print("abilities learned here: %d", #ns.GetKnownBars(O.selectedEncounter))
    ns.Print("boss mod: %s", ns.Schedule:HasBossMod() and "found" or "none")
end
