-------------------------------------------------------------------------------
-- CustomView.lua
--
-- The options tree, and the View selector that decides what it contains.
--
--   By Type    five fixed nodes — Buffs, Debuffs, Abilities, Items, Other —
--              derived from each tracker's kind. Nothing to organise and
--              nothing stored; it is a view OF the trackers, not a place they
--              live.
--   By Folder  user-created folders, plus "Ungrouped" for anything not filed.
--              Drag a tracker onto a folder to move it there.
--
-- ONE TREE WIDGET, two contents. Switching views swaps the node set, not the
-- layout, so row decoration, preview eyes, navigation paths and the combat
-- lock all stay on a single code path. The tab strip this replaced needed two
-- of everything.
--
-- EVERY NODE IS SORTED ALPHABETICALLY (groups first, then by name) — the order
-- BuildOptions already hands us. Folder membership is therefore a SET as far
-- as display is concerned: which folder a tracker is in is stored, where it
-- sits inside that folder is not. Dragging moves a tracker BETWEEN nodes and
-- nothing else.
--
-- FOLDERS ARE NAV ONLY. They have no effect on frames, positions, anchoring,
-- load conditions or the AuraContainer slot pipeline — nothing in Tracker.lua
-- reads any of this. A folder is a place a row is drawn, nothing more.
--
-- TERMINOLOGY. "Group" is already taken: a group-KIND tracker (kind ==
-- "group" / "abilitygroup" / "itemgroup") fans one tracker out over a list of
-- spell or item IDs. These containers are a different thing entirely, so they
-- are FOLDERS everywhere — in the code and in the UI. "Add Aura Group" and
-- "Add to folder" must never read as the same operation.
--
-- STORAGE is account-wide (db.global), keyed by the stable per-tracker uid
-- minted in Core.lua — NOT by cfgKey, whose integer id is per-profile and
-- recycled.
--
-- A uid that is missing from the CURRENT profile is not garbage: it may
-- belong to another profile. The resolve pass therefore FILTERS FOR DISPLAY
-- and never removes. The only prune is in DoDeleteCustom (Options.lua), and it
-- checks every profile first.
-------------------------------------------------------------------------------
local addonName, ns = ...
local HET = ns.addon

local ACD = LibStub("AceConfigDialog-3.0")
local ACR = LibStub("AceConfigRegistry-3.0")

local UNGROUPED_NODE = "folderRoot"
local UNGROUPED_NAME = "|cffaaaaaaUngrouped|r"

-- By Type's five nodes, in display order. `key` is what ns.TypeKeyFor returns.
local TYPE_NODES = {
    { key = "buffs",     node = "type_buffs",     name = "Buffs" },
    { key = "debuffs",   node = "type_debuffs",   name = "Debuffs" },
    { key = "abilities", node = "type_abilities", name = "Abilities" },
    { key = "items",     node = "type_items",     name = "Items" },
    { key = "other",     node = "type_other",     name = "Other" },
}

local function Notify()
    ACR:NotifyChange(addonName)
end

-- db.global, or nil before AceDB exists.
local function G()
    return HET.db and HET.db.global or nil
end

-------------------------------------------------------------------------------
-- View mode
-------------------------------------------------------------------------------
function ns.ViewMode()
    local g = G()
    return (g and g.viewMode == "folder") and "folder" or "type"
end

-- The root status table's `groups.selected` is one slot holding a tree
-- uniquevalue, and the two views use disjoint node keys ("type_buffs\1custom3"
-- vs "folder2\1custom3"). Switching without stashing would hand the tree a
-- value that does not exist in it, so ACD would fall back to its first row and
-- the user would lose their place every time they toggled. Session-local: a
-- remembered selection is not worth persisting across a reload.
local stashedSelection = {}

function ns.SetViewMode(v)
    v = (v == "folder") and "folder" or "type"
    local g = G()
    if not g then return end
    local old = ns.ViewMode()
    if old == v then return end

    local root = ACD:GetStatusTable(addonName, {})
    root.groups = root.groups or {}
    stashedSelection[old] = root.groups.selected
    g.viewMode = v
    -- Switching view means "show me the list", so nothing keeps the
    -- work-in-progress exemption from the Spec Filter (Options.lua).
    if ns.ClearSpecFilterExempt then ns.ClearSpecFilterExempt() end

    -- Rebuild NOW rather than waiting for the Notify below: ns._firstTreeNode
    -- is a side effect of BuildOptions, and the seeding two lines down has to
    -- read the version built under the NEW view.
    if ns._BuildOptions then pcall(ns._BuildOptions) end
    root.groups.selected = stashedSelection[v] or ns._firstTreeNode
    -- Top-level nodes start expanded, but only where the user has never
    -- touched them, so a deliberately collapsed one stays collapsed.
    root.groups.groups = root.groups.groups or {}
    for _, key in ipairs(ns.TreeNodeKeys()) do
        if root.groups.groups[key] == nil then root.groups.groups[key] = true end
    end

    Notify()
    -- DEFERRED re-feed, exactly as AddCustom does: this runs inside an
    -- AceConfig setter, and ACD:Open releases the very dropdown widget whose
    -- ActivateControl is still mid-flight (it then indexes a nil rootframe).
    C_Timer.After(0, function()
        if ACD.OpenFrames and ACD.OpenFrames[addonName] then
            ACD:Open(addonName)
        end
    end)
end

-- Every top-level node key in the CURRENT view. Used for expand-state seeding.
function ns.TreeNodeKeys()
    local out = {}
    if ns.ViewMode() == "type" then
        for i = 1, #TYPE_NODES do out[i] = TYPE_NODES[i].node end
    else
        local folders = ns.Folders()
        for i = 1, #folders do out[i] = "folder" .. folders[i].id end
        out[#out + 1] = UNGROUPED_NODE
    end
    return out
end

-------------------------------------------------------------------------------
-- Folder data model
-------------------------------------------------------------------------------
-- Bumped by every folder/membership mutation; the uid -> folderID memo below
-- caches against it. Deliberately separate from ns.customEntryGen: folder data
-- is account-wide and changes for reasons that have nothing to do with the
-- profile's customTrackers list.
local folderGen = 1
local function BumpFolderGen() folderGen = folderGen + 1 end
ns.BumpFolderGen = BumpFolderGen

function ns.Folders()
    local g = G()
    local f = g and g.folders
    if type(f) ~= "table" then return {} end
    return f
end

local function Members(id)
    local g = G()
    if not g then return nil end
    if type(g.folderMembers) ~= "table" then g.folderMembers = {} end
    return g.folderMembers[id]
end

local function MembersEnsure(id)
    local g = G()
    if not g then return {} end
    if type(g.folderMembers) ~= "table" then g.folderMembers = {} end
    local l = g.folderMembers[id]
    if type(l) ~= "table" then
        l = {}
        g.folderMembers[id] = l
    end
    return l
end

function ns.FolderByID(id)
    local folders = ns.Folders()
    for i = 1, #folders do
        if folders[i].id == id then return folders[i], i end
    end
    return nil
end

-- uid -> folderID for every uid that sits in a folder. Memoized against
-- folderGen; folder lists are tiny, but this is read once per tracker per
-- options build and once per row per drag frame.
local ownerMap, ownerGen = {}, -1
local function OwnerMap()
    if ownerGen ~= folderGen then
        wipe(ownerMap)
        ownerGen = folderGen
        local folders = ns.Folders()
        for i = 1, #folders do
            local id = folders[i].id
            local l = Members(id)
            if type(l) == "table" then
                for j = 1, #l do ownerMap[l[j]] = id end
            end
        end
    end
    return ownerMap
end

-- Folder id holding this tracker, or nil for Ungrouped.
function ns.FolderOf(cfgKey)
    local uid = ns.UIDOf and ns.UIDOf(cfgKey)
    if not uid then return nil end
    return OwnerMap()[uid]
end

-- Tree node key a tracker renders under in the CURRENT view.
--
-- Folder keys are stable strings ("folder3"), never name- or index-derived:
-- TreeGroup persists expand state keyed by uniquevalue, so a node key that
-- moved with the list order would shuffle every folder's collapsed state
-- whenever one was renamed or removed.
function ns.TreeNodeFor(cfgKey)
    if ns.ViewMode() == "type" then
        return "type_" .. (ns.TypeKeyFor and ns.TypeKeyFor(cfgKey) or "other")
    end
    local id = ns.FolderOf(cfgKey)
    return id and ("folder" .. id) or UNGROUPED_NODE
end

function ns.AddFolder(name)
    local g = G()
    if not g then return nil end
    if type(g.folders) ~= "table" then g.folders = {} end
    if type(g.folderMembers) ~= "table" then g.folderMembers = {} end
    -- NEVER recycled. If folder ids were reused the way custom tracker ids
    -- are, deleting a folder and creating another would silently inherit the
    -- dead one's membership list.
    local id = tonumber(g.folderNextID) or 1
    g.folderNextID = id + 1
    g.folders[#g.folders + 1] = {
        id = id,
        name = ns.UniqueFolderName(name or "New Folder"),
    }
    g.folderMembers[id] = {}
    BumpFolderGen()
    return id
end

-- Throw away every folder. Trackers are untouched — they simply fall back to
-- Ungrouped, which is computed, so dropping the lists IS the whole operation.
-- Reached only from an explicit, confirmed `/ba clearfolders`.
--
-- folderNextID is NOT reset. Folder ids must never be recycled, or a stale
-- membership list could be inherited by a new folder — the same rule that
-- makes them safe to key expand-state on.
function ns.ClearFolders()
    local g = G()
    if not g then return false end
    for i = #g.folders, 1, -1 do table.remove(g.folders, i) end
    if type(g.folderMembers) == "table" then
        for id in pairs(g.folderMembers) do g.folderMembers[id] = nil end
    end
    BumpFolderGen()
    return true
end

function ns.RenameFolder(id, name)
    local f = ns.FolderByID(id)
    if not f then return end
    name = tostring(name or ""):gsub("^%s+", ""):gsub("%s+$", "")
    if name == "" then return end
    f.name = ns.UniqueFolderName(name, f)
    BumpFolderGen()
end

-- Delete a folder and land somewhere sensible afterwards. The confirmation is
-- the caller's job: this is reached from the content pane's corner X
-- (DecorateTreeXs), which owns the popup the same way it does for the
-- delete-a-custom-tracker X.
function ns.DoDeleteFolder(id)
    if not ns.DeleteFolder(id) then return end
    Notify()
    C_Timer.After(0, function()
        ACD:SelectGroup(addonName, UNGROUPED_NODE)
        if ACD.OpenFrames and ACD.OpenFrames[addonName] then
            ACD:Open(addonName)
        end
    end)
end

-- Deleting a folder NEVER deletes trackers — its members fall back to
-- Ungrouped, which is computed, so dropping the membership list is the whole
-- operation. Any folder can go, including the last one.
function ns.DeleteFolder(id)
    local g = G()
    if not g then return false end
    local _, idx = ns.FolderByID(id)
    if not idx then return false end
    table.remove(g.folders, idx)
    if type(g.folderMembers) == "table" then g.folderMembers[id] = nil end
    BumpFolderGen()
    return true
end

-- Move a folder up or down in the tree order. Folders themselves are not
-- draggable (v1): a drag on a folder header is a drop TARGET, so making it a
-- drag SOURCE too would make every mis-grabbed folder a silent reorder.
-- Arrows in the folder's own pane are unambiguous and keyboard-reachable.
function ns.MoveFolder(id, delta)
    local g = G()
    if not g then return end
    local _, idx = ns.FolderByID(id)
    if not idx then return end
    local target = idx + delta
    if target < 1 or target > #g.folders then return end
    local f = table.remove(g.folders, idx)
    table.insert(g.folders, target, f)
    BumpFolderGen()
end

-- Per-folder NAME COLOUR, stored as { r, g, b } on the folder itself.
--
-- WHAT THE SWATCH SHOWS IS WHAT THE ROW SHOWS, white included. White used to
-- mean "clear the override and let the tree pick" — which rendered the row in
-- the tree's own yellow, so the picker said white and the list showed yellow.
-- Storing white like any other colour costs the reset affordance and buys an
-- honest control; the default for a folder with nothing stored is white too,
-- so an untouched folder and one explicitly set to white look the same.
--
-- Deliberately NOT part of a folder export payload (§14): it is a local
-- display preference, and a folder arriving from someone else should look like
-- the receiver's folders, not the sender's.
local FOLDER_DEFAULT_COLOR = { 1, 1, 1 }

function ns.FolderColor(id)
    local f = ns.FolderByID(id)
    local c = f and f.color
    if type(c) == "table" and c[1] then return c[1], c[2] or 1, c[3] or 1 end
    return FOLDER_DEFAULT_COLOR[1], FOLDER_DEFAULT_COLOR[2], FOLDER_DEFAULT_COLOR[3]
end

-- A non-number `r` clears the stored colour (back to the white default); any
-- real colour is stored as given.
function ns.SetFolderColor(id, r, g, b)
    local f = ns.FolderByID(id)
    if not f then return end
    if type(r) ~= "number" then
        f.color = nil
    else
        f.color = { r, g, b }
    end
    BumpFolderGen()
end

-- The folder's name as it appears in the tree. ALWAYS coloured — falling
-- through to the tree's own colour is what made a white pick render yellow.
function ns.FolderDisplayName(folder)
    local name = folder.name or "Folder"
    local c = folder.color
    if type(c) ~= "table" or not c[1] then c = FOLDER_DEFAULT_COLOR end
    return ("|cff%02x%02x%02x%s|r"):format(
        math.floor((c[1] or 1) * 255 + 0.5),
        math.floor((c[2] or 1) * 255 + 0.5),
        math.floor((c[3] or 1) * 255 + 0.5),
        name)
end

function ns.UniqueFolderName(base, selfFolder)
    base = tostring(base or "New Folder")
    local taken = {}
    local folders = ns.Folders()
    for i = 1, #folders do
        if folders[i] ~= selfFolder and folders[i].name then
            taken[folders[i].name:lower()] = true
        end
    end
    if not taken[base:lower()] then return base end
    local n = 2
    while taken[(base .. " " .. n):lower()] do n = n + 1 end
    return base .. " " .. n
end

-------------------------------------------------------------------------------
-- New Folder dialog
--
-- Two ways in, one window:
--   * "Add Folder" on the top row — just make the folder and select it.
--   * "Add to New Folder" on a tracker's Options tab, for the case the Folder
--     dropdown cannot serve: the folder you want does not exist yet. The
--     tracker lands in the result.
-- Either way the name and colour are chosen up front, rather than
-- create-then-hunt-for-it-then-rename-then-recolour.
--
-- A StaticPopup cannot carry a colour swatch, so this is an AceGUI window —
-- built through Transmission.lua's MakeDialogFrame/FitDialogHeight so the
-- pooled-frame hardening (leaked chrome, leaked content anchors) and the
-- fit-to-content sizing are shared rather than reimplemented.
-------------------------------------------------------------------------------
local AceGUI = LibStub("AceGUI-3.0")

local newFolderFrame

-- `cfgKey` is OPTIONAL: pass one to move that tracker into the new folder,
-- omit it to just create the folder.
function ns.ShowNewFolderDialog(cfgKey)
    if InCombatLockdown() then return end
    if not ns.MakeDialogFrame then return end
    if newFolderFrame then
        newFolderFrame:Release()
        newFolderFrame = nil
    end

    -- The tracker is identified by its UID for the life of this window, not by
    -- the cfgKey the caller passed. cfgKey ids are per-profile and RECYCLED, so
    -- a window left open while the tracker behind it is deleted and another is
    -- created would otherwise resolve to a different tracker entirely and file
    -- that one away instead.
    local uid = cfgKey and ns.UIDOf and ns.UIDOf(cfgKey)
    if cfgKey and not uid then return end

    local f = ns.MakeDialogFrame("|cffff8c14Buzzard Auras|r — New Folder", 420, 240)
    -- No status strip, no native Close button, no resize grips: this window is
    -- a two-field form sized to its contents, and Cancel is the way out.
    if ns.StripDialogChrome then ns.StripDialogChrome(f) end
    f:SetCallback("OnClose", function(widget)
        -- The Blizzard colour picker is a shared global that our swatch handed
        -- its own closures to. Releasing the swatch back to the pool does not
        -- close it, and an OK/Cancel click after that would deliver a colour to
        -- whichever widget has since acquired the swatch.
        if ColorPickerFrame and ColorPickerFrame:IsShown() then
            ColorPickerFrame:Hide()
        end
        -- Put the furniture back BEFORE releasing: the frame is pooled, and
        -- the next addon to acquire it must not find its Close button gone.
        if ns.RestoreDialogChrome then ns.RestoreDialogChrome(widget) end
        AceGUI:Release(widget)
        if newFolderFrame == widget then newFolderFrame = nil end
    end)
    newFolderFrame = f

    -- Seeded with a name that is free right now. Typing an existing folder's
    -- name is still allowed — AddFolder's own UniqueFolderName numbers it.
    local name = ns.UniqueFolderName("New Folder")
    -- White means "no override" everywhere else in the folder colour code, so
    -- it is the right starting value: leave it alone and the folder renders in
    -- the list's own colour.
    local cr, cg, cb = 1, 1, 1

    local nameBox = AceGUI:Create("EditBox")
    nameBox:SetLabel("Folder Name")
    nameBox:SetFullWidth(true)
    nameBox:SetText(name)
    nameBox:DisableButton(true)
    f:AddChild(nameBox)

    local colour = AceGUI:Create("ColorPicker")
    colour:SetLabel("Folder Color")
    colour:SetHasAlpha(false)
    colour:SetColor(cr, cg, cb)
    colour:SetCallback("OnValueChanged", function(_, _, r, g, b)
        cr, cg, cb = r, g, b
    end)
    colour:SetCallback("OnValueConfirmed", function(_, _, r, g, b)
        cr, cg, cb = r, g, b
    end)
    f:AddChild(colour)

    -- Refusals land here rather than in the status strip, which this window
    -- does not have.
    local status = AceGUI:Create("Label")
    status:SetFullWidth(true)
    status:SetText("")
    f:AddChild(status)

    local create = AceGUI:Create("Button")
    create:SetText("Create")
    create:SetWidth(140)
    f:AddChild(create)

    local cancel = AceGUI:Create("Button")
    cancel:SetText("Cancel")
    cancel:SetWidth(140)
    -- Deferred close: hiding the frame from inside this very Button's OnClick
    -- releases the Button mid-callback (see ns.CloseDialogSoon).
    cancel:SetCallback("OnClick", function()
        if ns.CloseDialogSoon then ns.CloseDialogSoon(f) else f:Hide() end
    end)
    f:AddChild(cancel)

    -- Every refusal below says so. A Create button that plays its click sound
    -- and then does nothing reads as a broken addon, and the user just clicks
    -- it again. Re-fit afterwards: the line is empty until one of these fires,
    -- so the window has no room reserved for it.
    local function Refuse(why)
        status:SetText("|cffff8800" .. why .. "|r")
        if ns.FitDialogHeight then ns.FitDialogHeight(f, 180, 360) end
    end

    local function Accept()
        -- Re-read rather than trusting a cached value: the user can type and
        -- hit Create without the edit box ever firing OnEnterPressed.
        local typed = tostring(nameBox:GetText() or "")
        typed = typed:gsub("^%s+", ""):gsub("%s+$", "")
        if typed == "" then return Refuse("Give the folder a name.") end
        -- The combat lock is re-checked at the moment of the write, not just
        -- when the window opened — this window can sit open across a pull.
        if InCombatLockdown() then return Refuse("Not while in combat.") end
        -- CHECK THE TRACKER BEFORE CREATING THE FOLDER. The panel behind this
        -- window stays live, so the tracker can be deleted while it is open —
        -- and creating the folder first would leave an empty one behind with
        -- nothing to say why. (Nothing to check when opened from the top row:
        -- there is no tracker in play.)
        local cur
        if uid then
            cur = ns.CFGKeyForUID and ns.CFGKeyForUID(uid)
            if not cur then
                return Refuse("That tracker no longer exists.")
            end
        end
        local id = ns.AddFolder(typed)
        if not id then return Refuse("Could not create the folder.") end
        ns.SetFolderColor(id, cr, cg, cb)
        if uid then ns.MoveTracker(uid, id) end
        if ns.CloseDialogSoon then ns.CloseDialogSoon(f) else f:Hide() end
        Notify()
        -- Land on whatever the user was actually acting on: the tracker that
        -- just moved (its row now lives under a different parent), or the new
        -- folder itself when there was no tracker.
        C_Timer.After(0, function()
            if cur then
                if ns.SelectTracker then ns.SelectTracker(cur) end
            else
                ACD:SelectGroup(addonName, "folder" .. id)
            end
            if ACD.OpenFrames and ACD.OpenFrames[addonName] then
                ACD:Open(addonName)
            end
        end)
    end
    create:SetCallback("OnClick", Accept)
    nameBox:SetCallback("OnEnterPressed", Accept)

    if ns.FitDialogHeight then ns.FitDialogHeight(f, 180, 360) end
    nameBox:SetFocus()
    nameBox:HighlightText()
end

-- Remove a uid from every folder list. Returns true if anything changed.
local function StripUID(uid)
    local changed = false
    local folders = ns.Folders()
    for i = 1, #folders do
        local l = Members(folders[i].id)
        if type(l) == "table" then
            for j = #l, 1, -1 do
                if l[j] == uid then
                    table.remove(l, j)
                    changed = true
                end
            end
        end
    end
    if changed then BumpFolderGen() end
    return changed
end

-- Move a tracker into `folderID`; nil means Ungrouped, which is computed, so
-- the strip IS the whole operation. Accepts a cfgKey or a raw uid.
--
-- There is no INDEX: every node renders alphabetically, so where a uid sits in
-- its folder's list means nothing. The list is a set that happens to be stored
-- as an array.
function ns.MoveTracker(cfgKeyOrUID, folderID)
    if type(cfgKeyOrUID) ~= "string" then return end
    -- Dual-mode argument, so it has to be checked rather than trusted: a
    -- cfgKey that is not live in THIS profile would otherwise fall through as
    -- itself and be filed as a member that resolves to nothing, renders
    -- nothing, and is never pruned.
    local uid = ns.UIDOf and ns.UIDOf(cfgKeyOrUID)
    if not uid then
        uid = (ns.CFGKeyForUID and ns.CFGKeyForUID(cfgKeyOrUID)) and cfgKeyOrUID or nil
    end
    if not uid then return end
    -- VALIDATE FIRST. Stripping before the destination is checked turns a
    -- stale folder id into a silent un-filing rather than a no-op — reachable
    -- from the Folder dropdown, whose values are evaluated at feed time and so
    -- can still list a folder deleted since.
    if folderID ~= nil and not ns.FolderByID(folderID) then return end
    StripUID(uid)
    if folderID == nil then
        BumpFolderGen()
        return
    end
    local l = MembersEnsure(folderID)
    l[#l + 1] = uid
    BumpFolderGen()
end

-- Duplicate support: put the copy in the same folder as its source, so it
-- appears beside it (alphabetically, since the copy is named after it).
-- No-op when the source is Ungrouped — the copy lands there too.
function ns.InsertAfterInFolder(srcUID, newUID)
    if type(srcUID) ~= "string" or type(newUID) ~= "string" then return end
    local folderID = OwnerMap()[srcUID]
    if not folderID then return end
    local l = MembersEnsure(folderID)
    for i = 1, #l do
        if l[i] == srcUID then
            table.insert(l, i + 1, newUID)
            BumpFolderGen()
            return
        end
    end
end

-- Called from DoDeleteCustom ONLY (deletion is rare and user-driven, so the
-- cross-profile scan below costs nothing in practice).
--
-- The scan reads OTHER profiles' raw tables — the only place in the addon
-- that does. It is necessary because folder membership is account-wide while
-- customTrackers is per-profile: a uid deleted here may still be live in a
-- profile that is not currently loaded (profile "Copy From" duplicates
-- entries uid and all), and stripping it would silently empty that profile's
-- folder.
function ns.PruneFolderUID(uid)
    if type(uid) ~= "string" then return end
    local sv = HET.db and HET.db.sv
    local profiles = sv and sv.profiles
    if type(profiles) == "table" then
        for _, prof in pairs(profiles) do
            local l = type(prof) == "table" and prof.customTrackers
            if type(l) == "table" then
                for i = 1, #l do
                    if type(l[i]) == "table" and l[i].uid == uid then return end
                end
            end
        end
    end
    StripUID(uid)
end

-- Drop any folderMembers list whose folder no longer exists. Unconditionally
-- safe (folder ids are global and never recycled) and cheap, so it runs once
-- per options build in the custom view.
local function PruneDeadFolderLists()
    local g = G()
    if not g or type(g.folderMembers) ~= "table" then return end
    local live = {}
    local folders = ns.Folders()
    for i = 1, #folders do live[folders[i].id] = true end
    local changed = false
    for id in pairs(g.folderMembers) do
        if not live[id] then
            g.folderMembers[id] = nil
            changed = true
        end
    end
    if changed then BumpFolderGen() end
end

-------------------------------------------------------------------------------
-- Node naming
-------------------------------------------------------------------------------
-- Disabled trackers read greyed in the custom view, but NOT via the tree's
-- own `disabled` flag: UpdateButton calls button:EnableMouse(false) on a
-- disabled line (AceGUIContainer-TreeGroup.lua), which would make a greyed row
-- impossible to drag and would kill its hover and tooltip. Selection has to
-- keep working too — re-enabling a tracker happens inside its own pane, so a
-- disabled row the user cannot reach is a dead end.
--
-- Existing colour codes are stripped first: WoW's |r resets to the default
-- colour rather than popping to the enclosing one, so wrapping a name that
-- already contains |cff66ccff...|r (built-ins) would grey only the leading
-- bracket. Texture escapes (|T...|t) are left alone.
local function GreyName(name)
    local stripped = (tostring(name):gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""))
    return "|cff808080" .. stripped .. "|r"
end

-------------------------------------------------------------------------------
-- Folder pane (shown when a folder ROW is selected)
-------------------------------------------------------------------------------
local function FolderPaneArgs(folder, memberCount, index, total)
    local id = folder.id
    return {
        -- Reorder arrows, matching the debuff-priority arrows in Buzzard
        -- Frames' Raid/Party Debuff section: image-only execute buttons on the
        -- Blizzard chat scroll art. The imageCoords crop is what makes them
        -- sit together — those textures carry transparent margins baked in,
        -- and without cropping them out the pair reads as two widely spaced
        -- smudges rather than one control.
        folderUp = {
            type = "execute",
            name = "",
            desc = "Move this folder UP the list.",
            order = 0.05,
            width = 0.15,
            image = "Interface\\ChatFrame\\UI-ChatIcon-ScrollUp-Up",
            imageCoords = { 0.15, 0.85, 0.15, 0.85 },
            imageWidth = 24, imageHeight = 24,
            disabled = function() return index <= 1 or InCombatLockdown() end,
            func = function()
                ns.MoveFolder(id, -1)
                Notify()
            end,
        },
        folderDown = {
            type = "execute",
            name = "",
            desc = "Move this folder DOWN the list.",
            order = 0.06,
            width = 0.15,
            image = "Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Up",
            imageCoords = { 0.15, 0.85, 0.15, 0.85 },
            imageWidth = 24, imageHeight = 24,
            disabled = function() return index >= total or InCombatLockdown() end,
            func = function()
                ns.MoveFolder(id, 1)
                Notify()
            end,
        },
        folderName = {
            type = "input",
            name = "Folder Name",
            order = 0.1,
            width = 1.3,
            get = function() return folder.name end,
            set = function(_, val)
                ns.RenameFolder(id, val)
                Notify()
            end,
        },
        folderColor = {
            type = "color",
            name = "Folder Color",
            desc = "Colour this folder's name in the list.",
            order = 0.15,
            width = 0.6,
            hasAlpha = false,
            get = function() return ns.FolderColor(id) end,
            set = function(_, r, g, b)
                ns.SetFolderColor(id, r, g, b)
                Notify()
            end,
        },
        folderCount = {
            type = "description",
            name = function()
                return "|cffaaaaaa" .. memberCount
                    .. (memberCount == 1 and " tracker" or " trackers")
                    .. " in this folder.|r"
            end,
            order = 0.2,
            width = "full",
        },
        folderExport = {
            type = "execute",
            name = "Export Folder",
            desc = "Produce a |cffffd100!BZA|r string for this folder and every"
                .. " tracker in it, to paste elsewhere. To link it in chat"
                .. " instead, open the chat box you want to post in and"
                .. " shift-click the folder's name in the list at the left.",
            order = 0.4,
            width = 1.0,
            hidden = function() return not ns.PayloadForFolder end,
            -- `disabled` is deepest-defined-WINS, so this REPLACES the folder
            -- group's combat lock and has to restate it. The emptiness test
            -- reads the STORED member list, not memberCount — the latter
            -- counts only what survived the Spec Filter, and a folder whose
            -- members are all filtered out still exports fine.
            disabled = function()
                if InCombatLockdown() then return true end
                local l = Members(id)
                return not (type(l) == "table" and #l > 0)
            end,
            func = function()
                if not ns.PayloadForFolder then return end
                local payload = ns.PayloadForFolder(id)
                if payload then
                    ns.ShowExportDialog(payload, folder.name, id, true)
                end
            end,
        },
    }
end

StaticPopupDialogs["BUZZARDAURAS_CLEAR_FOLDERS"] = {
    text = "Delete every folder?"
        .. "\n\nAll trackers move to Ungrouped."
        .. "\n\nNo tracker is deleted and no tracker setting changes — this"
        .. " only affects how the options list is organised.",
    button1 = YES,
    button2 = NO,
    OnAccept = function()
        if not ns.ClearFolders() then return end
        Notify()
        C_Timer.After(0, function()
            ACD:SelectGroup(addonName, UNGROUPED_NODE)
            if ACD.OpenFrames and ACD.OpenFrames[addonName] then
                ACD:Open(addonName)
            end
        end)
        print("|cffff8c14Buzzard Auras|r All folders deleted.")
    end,
    -- The options panel sits at FULLSCREEN_DIALOG strata; StaticPopups default
    -- to DIALOG and would open BEHIND it.
    OnShow = function(self)
        self._bzaOldStrata = self:GetFrameStrata()
        self:SetFrameStrata("TOOLTIP")
    end,
    OnHide = function(self)
        if self._bzaOldStrata then
            self:SetFrameStrata(self._bzaOldStrata)
            self._bzaOldStrata = nil
        end
    end,
    timeout = 0,
    whileDead = 1,
    hideOnEscape = 1,
    preferredIndex = 3,
}

StaticPopupDialogs["BUZZARDAURAS_DELETE_FOLDER"] = {
    text = "Delete the folder \"%s\"?\n\nThe trackers inside it are NOT deleted"
        .. " — they move to Ungrouped.",
    button1 = YES,
    button2 = NO,
    OnAccept = function(self, data)
        if data and data.fn then data.fn() end
    end,
    -- The options panel sits at FULLSCREEN_DIALOG strata; StaticPopups default
    -- to DIALOG and would open BEHIND it.
    OnShow = function(self)
        self._bzaOldStrata = self:GetFrameStrata()
        self:SetFrameStrata("TOOLTIP")
    end,
    OnHide = function(self)
        if self._bzaOldStrata then
            self:SetFrameStrata(self._bzaOldStrata)
            self._bzaOldStrata = nil
        end
    end,
    timeout = 0,
    whileDead = 1,
    hideOnEscape = 1,
    preferredIndex = 3,
}

-------------------------------------------------------------------------------
-- The tree args
--
-- `nodes` is an ordered array of { cfgKey, node, group, enabled }, one per
-- tracker that survived the Spec Filter, in the tree sort BuildOptions applies
-- (groups first, then alphabetical). Folders impose their own order on top.
-------------------------------------------------------------------------------
-- "Sort Order" (Addon Options). "alpha" is one alphabetical run per node;
-- "active" keeps that order but pushes disabled trackers below the enabled
-- ones — a STABLE PARTITION, not a second sort, so both halves stay in exactly
-- the order BuildOptions produced (groups first, then alphabetical).
function ns.SortOrder()
    local g = G()
    return (g and g.sortOrder == "active") and "active" or "alpha"
end

function ns.SetSortOrder(v)
    local g = G()
    if not g then return end
    g.sortOrder = (v == "active") and "active" or "alpha"
end

local function OrderMembers(members)
    if ns.SortOrder() ~= "active" then return members end
    local on, off = {}, {}
    for i = 1, #members do
        local m = members[i]
        if m.enabled then on[#on + 1] = m else off[#off + 1] = m end
    end
    for i = 1, #off do on[#on + 1] = off[i] end
    return on
end

-- Shared by both builders. `nodes` arrives in tree-sort order, so appending in
-- that order IS the alphabetical sort — the ONLY thing that reorders it is the
-- Sort Order option's enabled/disabled partition above.
--
-- Returns the args table for one node plus its member count and the cfgKeys it
-- holds (the latter feeds the node's preview eye).
local function NodeArgs(nodeKey, members, state)
    members = OrderMembers(members)
    local nargs, count, keys = {}, 0, {}
    for i = 1, #members do
        local n = members[i]
        count = count + 1
        n.group.order = count
        if not n.enabled then n.group.name = GreyName(n.group.name) end
        nargs[n.node] = n.group
        keys[#keys + 1] = n.cfgKey
        local value = nodeKey .. "\001" .. n.node
        state.firstNode = state.firstNode or value
        if n.enabled then
            state.firstEnabledNode = state.firstEnabledNode or value
        end
    end
    state.nodeMembers[nodeKey] = keys
    return nargs, count
end

local function NodeGroup(name, icon, order, args)
    return {
        type = "group",
        name = name,
        icon = icon,
        order = order,
        -- Top-level rows ARE selectable — folders own a pane (rename, delete,
        -- count, export) and type nodes own a short description. So no depth
        -- guard, only the combat lock, restated because `disabled` is
        -- deepest-defined-wins and GROUP entries must never be
        -- combat-disabled (ACD skips tree selection for a disabled group
        -- entry, blanking the pane).
        disabled = function(info)
            return info.type ~= "group" and InCombatLockdown()
        end,
        args = args,
    }
end

-- BY TYPE: five fixed nodes derived from each tracker's kind. Nothing is
-- stored and nothing is user-editable — this is a view OF the trackers.
local function BuildTypeArgs(nodes, state)
    local buckets = {}
    for i = 1, #TYPE_NODES do buckets[TYPE_NODES[i].key] = {} end
    for i = 1, #nodes do
        local n = nodes[i]
        local key = ns.TypeKeyFor and ns.TypeKeyFor(n.cfgKey) or "other"
        local b = buckets[key] or buckets.other
        b[#b + 1] = n
    end

    local args = {}
    for i = 1, #TYPE_NODES do
        local t = TYPE_NODES[i]
        local nargs, count = NodeArgs(t.node, buckets[t.key], state)
        if count == 0 then
            nargs.emptyNote = {
                type = "description",
                name = "|cffaaaaaaNo " .. t.name:lower() .. " trackers yet.|r",
                order = 0.2,
                width = "full",
            }
        end
        nargs.typeNote = {
            type = "description",
            -- Evaluated per feed, so it tracks the Sort Order option rather
            -- than asserting one ordering forever.
            name = function()
                return "|cffaaaaaa" .. count
                    .. (count == 1 and " tracker" or " trackers")
                    .. (ns.SortOrder() == "active"
                        and ". Enabled first, then disabled; alphabetical"
                            .. " within each, groups first.|r"
                        or ". Sorted alphabetically; groups first.|r")
            end,
            order = 0.1,
            width = "full",
        }
        args[t.node] = NodeGroup(t.name, nil, i, nargs)
    end
    return args
end

-- BY FOLDER: the user's folders, plus Ungrouped for anything not filed.
local function BuildFolderArgs(nodes, state)
    PruneDeadFolderLists()

    local owners = OwnerMap()
    local byFolder, ungrouped = {}, {}
    local folders = ns.Folders()
    for i = 1, #folders do byFolder[folders[i].id] = {} end
    for i = 1, #nodes do
        local n = nodes[i]
        local uid = ns.UIDOf and ns.UIDOf(n.cfgKey)
        -- FILTER FOR DISPLAY, never write: a uid with no live tracker here may
        -- be perfectly valid in another profile, and one the Spec Filter hides
        -- is still a member.
        local fid = uid and owners[uid]
        local bucket = (fid and byFolder[fid]) or ungrouped
        bucket[#bucket + 1] = n
    end

    local args = {}
    for i = 1, #folders do
        local folder = folders[i]
        local nodeKey = "folder" .. folder.id
        local nargs, count = NodeArgs(nodeKey, byFolder[folder.id], state)
        local pane = FolderPaneArgs(folder, count, i, #folders)
        if count == 0 then
            pane.emptyNote = {
                type = "description",
                name = "|cffaaaaaaThis folder is empty. You can drag and drop"
                    .. " trackers in the list at the left to move them between"
                    .. " folders.|r",
                order = 0.25,
                width = "full",
            }
        end
        for k, v in pairs(nargs) do pane[k] = v end
        -- NO `hidden = not have`. An empty folder must stay visible or it stops
        -- being a drop target — which matters both when the Spec Filter empties
        -- it and the moment after the user creates it.
        -- NO ICON. The only art available was the overlapping-squares
        -- GroupIcon, which already means "this row is a group-KIND tracker" —
        -- on a folder it says the wrong thing twice over.
        args[nodeKey] = NodeGroup(ns.FolderDisplayName(folder), nil, i, pane)
    end

    local uargs, ucount = NodeArgs(UNGROUPED_NODE, ungrouped, state)
    uargs.ungroupedNote = {
        type = "description",
        name = ucount == 0
            and "|cffaaaaaaEvery tracker is in a folder.|r"
            or "|cffaaaaaaTrackers not in any folder. Drag them onto a folder,"
                .. " or pick one from a tracker's own Folder dropdown.|r",
        order = 0.1,
        width = "full",
    }
    -- Always last, and always present: it is the only place a tracker can be
    -- dropped to take it OUT of a folder.
    args[UNGROUPED_NODE] = NodeGroup(UNGROUPED_NAME, nil, 9000, uargs)
    return args
end

function ns.BuildTreeArgs(nodes)
    local state = { firstNode = nil, firstEnabledNode = nil, nodeMembers = {} }
    local args
    if ns.ViewMode() == "type" then
        args = BuildTypeArgs(nodes, state)
    else
        args = BuildFolderArgs(nodes, state)
    end
    -- Read by DecorateTreeXs to give each top-level row a preview eye that
    -- drives everything under it.
    ns._treeNodeMembers = state.nodeMembers
    -- hetOpen reads this to seed the first open's selection. ENABLED trackers
    -- are preferred: a disabled tracker's pane hides almost everything (its own
    -- Enable toggle aside), so opening onto one looks like a broken panel.
    ns._firstTreeNode = state.firstEnabledNode or state.firstNode
    return args
end

-------------------------------------------------------------------------------
-- Drag and drop
--
-- Tree rows are plain pooled Buttons ("AceGUI30TreeButton%d") carrying
-- .uniquevalue, with only OnClick / OnDoubleClick / OnEnter / OnLeave bound
-- and nothing registered for drag — so the drag slot is genuinely free, and
-- taking it needs no widget replacement. (Re-registering the TreeGroup widget
-- type globally would hit every addon sharing this AceGUI copy.)
--
-- The buttons are POOLED ACROSS ADDONS, so every row this decorator touches
-- must be explicitly CLEARED when it is not one of ours in the custom view —
-- otherwise a row carrying drag scripts into another addon's panel misbehaves
-- there.
-------------------------------------------------------------------------------
local drag = {
    active   = false,
    srcKey   = nil,    -- cfgKey, NEVER the button: a NotifyChange mid-drag
                       -- repools every row, so the drag has to survive a
                       -- rebuild by name.
    srcUID   = nil,
    srcFolder = nil,   -- folder id it started in (nil = Ungrouped), so a drop
                       -- back into the same node is a no-op
    widget   = nil,
    ghost    = nil,
    hilite   = nil,
}

local AUTOSCROLL_PX = 20

local function CursorXY()
    local scale = UIParent:GetEffectiveScale()
    local x, y = GetCursorPosition()
    return x / scale, y / scale
end

local function EnsureGhost()
    if drag.ghost then return drag.ghost end
    local f = CreateFrame("Frame", nil, UIParent)
    f:SetFrameStrata("TOOLTIP")
    f:SetSize(200, 20)
    f:EnableMouse(false)
    local bg = f:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(f)
    bg:SetColorTexture(0, 0, 0, 0.7)
    local icon = f:CreateTexture(nil, "ARTWORK")
    icon:SetSize(16, 16)
    icon:SetPoint("LEFT", 2, 0)
    local text = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    text:SetPoint("LEFT", icon, "RIGHT", 4, 0)
    text:SetPoint("RIGHT", -4, 0)
    text:SetJustifyH("LEFT")
    f.icon, f.text = icon, text
    f:Hide()
    drag.ghost = f
    return f
end

local function EnsureIndicators(treeframe)
    if not drag.hilite then
        local h = treeframe:CreateTexture(nil, "OVERLAY")
        h:SetColorTexture(1, 0.82, 0, 0.25)
        h:Hide()
        drag.hilite = h
    end
    -- Parented to whichever tree frame started the drag; a later drag on a
    -- rebuilt widget re-parents it rather than leaking a texture per drag.
    if drag.hilite:GetParent() ~= treeframe then drag.hilite:SetParent(treeframe) end
    return drag.hilite
end

-- "folder1\1custom3" -> "custom3"; a bare top-level value passes through.
local function TrackerNodeOf(uniquevalue)
    if type(uniquevalue) ~= "string" then return nil end
    local tail = uniquevalue:match("[^\001]+$")
    return tail
end

-- Is this a top-level row, and if so which folder does it stand for?
--   id   = folder id, or nil for the Ungrouped row
--   ok   = true when this row is a legal DROP TARGET at all
--
-- The tree is exactly TWO levels: a top-level row, or one tracker under it.
-- (Tracker node groups declare childGroups = "tab", which stops ACD's
-- BuildSubGroups recursion, so their own sub-panes never become rows here.)
-- Anything else — "addonOptions", a type node, anything deeper — is not
-- something the folder model can place, and must cancel rather than guess.
local function DropTargetOfValue(uniquevalue)
    if type(uniquevalue) ~= "string" then return nil, false end
    local _, depth = uniquevalue:gsub("\001", "")
    if depth > 1 then return nil, false end
    local head = uniquevalue:match("^[^\001]+")
    if head == UNGROUPED_NODE then return nil, true end
    local id = head and tonumber(head:match("^folder(%d+)$"))
    return id, id ~= nil
end

local function EndDrag()
    if drag.ghost then
        drag.ghost:Hide()
        drag.ghost:SetScript("OnUpdate", nil)
    end
    if drag.hilite then drag.hilite:Hide() end
    drag.active = false
    drag.srcKey = nil
    drag.srcUID = nil
    drag.srcFolder = nil
    drag.widget = nil
end
ns.EndCustomViewDrag = EndDrag

-- Shown rows of a tree widget, top to bottom, with their screen rects.
local function ShownRows(widget)
    local out = {}
    local buttons = widget.buttons
    if type(buttons) ~= "table" then return out end
    for i = 1, #buttons do
        local b = buttons[i]
        if type(b) == "table" and type(b.IsShown) == "function" and b:IsShown()
            and b.uniquevalue and b:GetTop() then
            out[#out + 1] = b
        end
    end
    return out
end

-- Which NODE would a drop at (cx, cy) land in?
--   { folderID = n }    that folder
--   { folderID = nil }  Ungrouped, i.e. take it out of whatever folder it is in
--   nil                 nothing droppable there; cancel
--
-- There is no before/after: every node renders alphabetically, so a drop is
-- only ever "belongs to this node now". A tracker row therefore resolves to
-- ITS OWN node — dropping a tracker onto a sibling means the same thing as
-- dropping it on their shared header, which is what a user aiming at a folder
-- but hitting one of its rows intends.
local function ResolveDrop(widget, cx, cy)
    local treeframe = widget.treeframe
    if not treeframe then return nil end
    local l, r = treeframe:GetLeft(), treeframe:GetRight()
    local t, b = treeframe:GetTop(), treeframe:GetBottom()
    if not (l and r and t and b) then return nil end
    if cx < l or cx > r or cy < b or cy > t then return nil end

    local rows = ShownRows(widget)
    -- CLAMP into the row band before hit-testing. TreeGroup anchors its first
    -- button 10px below the tree frame's top, so there is a strip inside the
    -- frame that no row covers, and a release there should read as "the first
    -- row" rather than falling through to whatever is last.
    local firstRow, lastRow = rows[1], rows[#rows]
    if not firstRow then return nil end
    local bandTop, bandBottom = firstRow:GetTop(), lastRow:GetBottom()
    if bandTop and cy > bandTop then cy = bandTop end
    if bandBottom and cy < bandBottom then cy = bandBottom end

    for i = 1, #rows do
        local row = rows[i]
        local rt, rb = row:GetTop(), row:GetBottom()
        if rt and rb and cy <= rt and cy >= rb then
            -- NOT ONE OF OURS. The root tree also carries "addonOptions"
            -- (always the last row). Without this guard it would parse as
            -- "no folder" and a drop on it — or on the blank space beside it —
            -- would quietly pull the tracker out of its folder.
            local folderID, ok = DropTargetOfValue(row.uniquevalue)
            if not ok then return nil end
            return { folderID = folderID, row = row }
        end
    end
    return nil
end

-- Highlight the whole destination NODE — its header row and every row under
-- it. With no insertion position to show, "this is where it will go" is the
-- only thing left to say, and saying it about the group rather than a gap is
-- what makes a drop onto a sibling row read correctly.
local function UpdateIndicators(widget, drop)
    local treeframe = widget.treeframe
    if not treeframe then return end
    local hilite = EnsureIndicators(treeframe)
    hilite:Hide()
    if not drop then return end

    local rows = ShownRows(widget)
    local top, bottom
    for i = 1, #rows do
        local fid, ok = DropTargetOfValue(rows[i].uniquevalue)
        -- Rows of the destination node: its header (depth 0) and its children
        -- (depth 1), both of which report the same folder id.
        if ok and fid == drop.folderID then
            top = top or rows[i]
            bottom = rows[i]
        end
    end
    if not top then return end
    hilite:ClearAllPoints()
    hilite:SetPoint("TOPLEFT", top, "TOPLEFT", 0, 0)
    hilite:SetPoint("BOTTOMRIGHT", bottom, "BOTTOMRIGHT", 0, 0)
    hilite:Show()
end

local function AutoScroll(widget, cy)
    if not widget.showscroll then return end
    local sb = widget.scrollbar
    local treeframe = widget.treeframe
    if not (sb and treeframe) then return end
    local t, b = treeframe:GetTop(), treeframe:GetBottom()
    if not (t and b) then return end
    local minV, maxV = sb:GetMinMaxValues()
    local v = sb:GetValue()
    if cy > t - AUTOSCROLL_PX and v > minV then
        sb:SetValue(v - 1)
    elseif cy < b + AUTOSCROLL_PX and v < maxV then
        sb:SetValue(v + 1)
    end
end

local function Ghost_OnUpdate()
    if not drag.active then return end
    local widget = drag.widget
    if not (widget and widget.treeframe) then EndDrag() return end
    local cx, cy = CursorXY()
    local g = EnsureGhost()
    g:ClearAllPoints()
    g:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", cx + 12, cy - 4)
    AutoScroll(widget, cy)
    UpdateIndicators(widget, ResolveDrop(widget, cx, cy))
end

local function Row_OnDragStart(button)
    if InCombatLockdown() then return end
    local widget = button.obj
    if not widget then return end
    local uv = button.uniquevalue
    -- Top-level rows are drop TARGETS, never sources: making a folder header
    -- draggable would turn every mis-grab into a silent reorder.
    if type(uv) ~= "string" or not uv:find("\001", 1, true) then return end
    local node = TrackerNodeOf(uv)
    if not node then return end
    local cfgKey = ns.CFGForNode and ns.CFGForNode(node) or node
    if not (ns.trackerConfigs and ns.trackerConfigs[cfgKey]) then return end
    local uid = ns.UIDOf and ns.UIDOf(cfgKey)
    if not uid then return end

    drag.active = true
    drag.srcKey = cfgKey
    drag.srcUID = uid
    drag.srcFolder = ns.FolderOf(cfgKey)
    drag.widget = widget

    local g = EnsureGhost()
    g.text:SetText(button.text and button.text:GetText() or cfgKey)
    local tex = button.icon and button.icon:GetTexture()
    if tex then
        g.icon:SetTexture(tex)
        g.icon:Show()
    else
        g.icon:Hide()
    end
    g:SetWidth(math.max(120, (button:GetWidth() or 160)))
    g:Show()
    g:SetScript("OnUpdate", Ghost_OnUpdate)
end

local function Row_OnDragStop(button)
    if not drag.active then EndDrag() return end
    local widget = drag.widget
    local cfgKey, uid = drag.srcKey, drag.srcUID
    local srcFolder = drag.srcFolder
    local cx, cy = CursorXY()
    local drop = widget and ResolveDrop(widget, cx, cy)
    EndDrag()
    if not (drop and cfgKey and uid) then return end
    -- Already there. Covers the ordinary "grabbed it by accident, let go"
    -- gesture, and every drop onto a sibling row of its own node.
    if drop.folderID == srcFolder then return end

    ns.MoveTracker(uid, drop.folderID)
    Notify()
    -- DEFERRED: the drop ends on a button the rebuild releases. Same class of
    -- problem as the ActivateControl crash the AddCustom flow defers around.
    C_Timer.After(0, function()
        local node = ns.TreeNodeFor(cfgKey)
        if node then
            ACD:SelectGroup(addonName, node,
                (ns.NodeForCFG and ns.NodeForCFG(cfgKey)) or cfgKey)
        end
        if ACD.OpenFrames and ACD.OpenFrames[addonName] then
            ACD:Open(addonName)
        end
    end)
end

-- Called from the wrapped RefreshTree in Options.lua, for EVERY TreeGroup in
-- the game. Wires drag only on OUR ROOT tree (the folder list); clears it
-- everywhere else, because the buttons are pooled across addons — including on
-- our own per-tracker "Trackers" trees, which live deeper and are not
-- reorderable this way.
function ns.DecorateTreeDnD(widget)
    local user = type(widget.GetUserDataTable) == "function" and widget:GetUserDataTable()
    local path = user and user.path
    -- BY TYPE has nothing to drag into: its five nodes are derived from each
    -- tracker's kind, not chosen. Drag is a folder-view gesture only.
    local wire = user and user.appName == addonName and path and #path == 0
        and ns.ViewMode() == "folder"

    local buttons = widget.buttons
    if type(buttons) ~= "table" then return end
    for i = 1, #buttons do
        local b = buttons[i]
        if type(b) == "table" and type(b.RegisterForDrag) == "function" then
            if wire then
                if not b._bzaDnD then
                    b._bzaDnD = true
                    b:RegisterForDrag("LeftButton")
                    b:SetScript("OnDragStart", Row_OnDragStart)
                    b:SetScript("OnDragStop", Row_OnDragStop)
                end
            elseif b._bzaDnD then
                b._bzaDnD = nil
                b:RegisterForDrag()
                b:SetScript("OnDragStart", nil)
                b:SetScript("OnDragStop", nil)
            end
        end
    end
end

-------------------------------------------------------------------------------
-- Teardown guards
--
-- OnDragStop fires when the mouse is released off-frame, so the ordinary
-- cancel path is covered. Entering combat and closing the panel are not.
-------------------------------------------------------------------------------
local dragWatcher = CreateFrame("Frame")
dragWatcher:RegisterEvent("PLAYER_REGEN_DISABLED")
dragWatcher:SetScript("OnEvent", function()
    if drag.active then EndDrag() end
end)
