-- TOG Tools — Smack tab (UI)
--
-- FGI-Filters-style: a top form (Name + Trigger + threshold + Channels +
-- Lines + Cooldown + Save) and a RowList below listing every saved filter.
-- Click a row to load it into the form for editing; Save with an existing
-- name overwrites in place, a new name appends a row. The "On" checkbox in
-- the list is the active/passive toggle.
--
-- The threshold (%) field is trigger-conditional — only the percent-based
-- triggers show it, reshaping the criteria row like GratzTab does.
--
-- ON THE RESEMBLANCE TO GratzTab.lua, WHICH IS DELIBERATE AND STAYS (2026-08-16).
-- `dupscan` pairs these two files: buildForm (184 vs 231 lines), plus onSave,
-- onReset, onDelete, onToggle, resetForm, loadIntoForm and fmtChannels. That is a
-- real observation and the answer is still "two copies", for a reason worth
-- writing down so nobody re-opens it on the strength of the tool's output alone:
--
--   * The SHAPE is shared; the SCHEMA is not. Gratz persists scope, minPoints,
--     minLevel, maxLevel, guildInterval and a single message; Smack persists pct,
--     a lines[] array and a hotkey. loadIntoForm and buildForm are field-by-field
--     over those, so "sharing" them means inventing a form-description DSL and
--     rewriting both tabs against it -- a framework, not a refactor.
--   * They are actively DIVERGING, not converging. Smack gates triggers and
--     channels on Retail (secret unit health, a blocked combat log, the automatic
--     public-chat block) and has a keybind; Gratz has none of that and needs none.
--     A shared builder would grow a flag per divergence.
--   * What IS identical is leaf-sized and harmless: attachTip (5 lines, itself a
--     nil-guard around addon.GUI.AttachTooltip) and resetForm (2 lines).
--
-- What the duplication actually costs is CONVENTION DRIFT, and there is already
-- one instance: this file's fmtChannels answers "none" for both a nil and an
-- empty channel set, while GratzTab's answers a grey dash for nil and "none" for
-- empty. Same column, two different empties. If you touch one of these functions,
-- check its twin.

local _, addon = ...
local AceGUI = LibStub("AceGUI-3.0")

local Tab = addon.modules["smack"]
Tab.tabKey      = "smack"
Tab.configKey   = "smack"
Tab.label       = "Smack"
Tab.WINDOW_SIZE = { width = 720, height = 560, minWidth = 640, minHeight = 460 }
Tab.help = {
    title = "Smack",
    lines = {
        "Smack-talk: say a random line when a combat trigger hits. Build a named filter in the form above, then tick |cffffd700On|r in the list to activate it.",
        " ",
        "Pick one trigger per filter. Percent triggers (My HP, Target HP, Mana, Group member, Big hit) show a |cffffd700%|r field; the rest fire on the event itself.",
        " ",
        "Type one line per row in the Lines box — one is chosen at random each time. Placeholders: [me], [target], [hp], [targethp], [member].",
        " ",
        "Everything is |cffffd700combat-only|r. A line only fires on the configured crossing and won't repeat until you cross back; the per-filter cooldown (minutes, min 1) caps repeats.",
        " ",
        "|cffff5555Note:|r On Retail/Midnight, Blizzard blocks |cffffd700automatic|r public chat (|cffffd700Say / Yell / Emote|r) — an auto-trigger can't send to those there. Use |cffffd700Party / Raid / Guild|r for auto-triggers, or bind a |cffffd700hotkey|r (a keypress is a hardware event and IS allowed in any channel). Classic has no such block.",
        " ",
        "|cffff5555On Retail/Midnight:|r some triggers are hidden because the game blocks the data they need from addons — |cffffd700Target HP|r & |cffffd700Group member|r (other units' health is secret) and the combat-log ones |cffffd700Killing blow / Big hit / Group death / Bloodlust|r (the combat log is blocked there). They all work on |cffffd700Classic|r. Retail keeps |cffffd700My HP|r, |cffffd700My mana|r, |cffffd700Combat start/end|r, |cffffd700CC'd|r, |cffffd700I die|r — and the |cffffd700Manual hotkey|r, which works everywhere.",
        " ",
        "|cffffd700Hover any form field or column header for detail.|r",
    },
}

-- ---------------------------------------------------------------------------
-- Trigger metadata
-- ---------------------------------------------------------------------------
local TRIGGER_LABEL = {
    ["self-hp-below"]   = "My HP below %",
    ["self-hp-above"]   = "My HP recovers above %",
    ["target-hp-below"] = "Target HP below %",
    ["self-mana-below"] = "My mana below %",
    ["group-hp-below"]  = "Group member below %",
    ["self-bighit"]     = "I take a big hit",
    ["killing-blow"]    = "I land a killing blow",
    ["combat-start"]    = "Combat starts (pull)",
    ["combat-end"]      = "Combat ends (survived)",
    ["group-death"]     = "A group member dies",
    ["self-cc"]         = "I get feared / stunned / CC'd",
    ["self-death"]      = "I die",
    ["bloodlust"]       = "Bloodlust / Heroism active",
    ["manual"]          = "Manual (hotkey only)",
}
local TRIGGER_ORDER = {
    "manual",
    "self-hp-below", "self-hp-above", "target-hp-below", "self-mana-below",
    "group-hp-below", "self-bighit", "killing-blow", "combat-start",
    "combat-end", "group-death", "self-cc", "self-death", "bloodlust",
}

-- Triggers that take a percent threshold (show the % field).
local PCT_TRIGGER = {
    ["self-hp-below"]   = true,
    ["self-hp-above"]   = true,
    ["target-hp-below"] = true,
    ["self-mana-below"] = true,
    ["group-hp-below"]  = true,
    ["self-bighit"]     = true,
}

-- Per-trigger label + tooltip for the % field (semantics differ).
local function pctLabel(trigger)
    if trigger == "self-hp-above" then return "Above %" end
    if trigger == "self-bighit"  then return "Hit ≥ % HP" end
    return "Below %"
end

local TRIGGER_TIP = {
    ["self-hp-below"]   = "Fires when YOUR health drops below the % set. Re-arms once you climb back above it. The classic low-health smack.",
    ["self-hp-above"]   = "Fires when YOUR health recovers up past the % set (after dropping below it). A 'back in the fight' line.",
    ["target-hp-below"] = "Fires when your current TARGET's health drops below the % set. Re-arms on a fresh target, so each pull can taunt again. |cffff5555On Retail/Midnight the game hides enemy health from addons (it reads back as a protected value), so this can't fire there — it works on Classic.|r",
    ["self-mana-below"] = "Fires when YOUR mana drops below the % set. No-op for a class/form with no mana bar. Great for healers running dry.",
    ["group-hp-below"]  = "Fires when a PARTY/RAID member's health drops below the % set. Tracked per member. |cffff5555On Retail/Midnight the game hides other units' health from addons, so this can't fire there — it works on Classic.|r",
    ["self-bighit"]     = "Fires when a single hit you take is at least the % of your max health set. Talk crap about whatever just clobbered you. |cffff5555Classic only — it needs the combat log, which Blizzard blocks for addons on Retail/Midnight.|r",
    ["killing-blow"]    = "Fires when YOU land the killing blow on something (or someone). |cffff5555Classic only — it needs the combat log, which Blizzard blocks for addons on Retail/Midnight.|r",
    ["combat-start"]    = "Fires the moment you enter combat — a pull / battle-cry line.",
    ["combat-end"]      = "Fires when combat ends and you're still alive — a victory line.",
    ["group-death"]     = "Fires when a party/raid member dies. |cffff5555Classic only — it needs the combat log, which Blizzard blocks for addons on Retail/Midnight.|r",
    ["self-cc"]         = "Fires when you get feared, stunned, or otherwise crowd-controlled.",
    ["self-death"]      = "Fires when you die. You can still chat as a ghost, so last words land.",
    ["bloodlust"]       = "Fires when Bloodlust / Heroism / Time Warp (and friends) lands on you. |cffff5555Classic only — it needs the combat log, which Blizzard blocks for addons on Retail/Midnight|r (and never existed on Classic Era anyway).",
    ["manual"]          = "Never fires on its own — only when you press its |cffffd700hotkey|r (set below). A keypress is a hardware event, so this works |cffffd700everywhere, every version, even /say mid-boss|r. The on-demand smack button.",
}

local CHANNEL_LABEL = {
    guild = "Guild", officer = "Officer", party = "Party", raid = "Raid",
    say = "Say", yell = "Yell", instance = "Instance", emote = "Emote",
}
local CHANNEL_TIP = {
    guild    = "Send to guild chat. Skipped silently when you're not in a guild.",
    officer  = "Send to officer chat. Skipped silently when you're not in a guild (or lack officer permission).",
    party    = "Send to your party. Skipped silently when you're not in a group.",
    raid     = "Send to your raid. Skipped silently when you're not in a raid.",
    say      = "Send to /say. |cffff5555On Retail/Midnight, Blizzard blocks AUTOMATIC public chat|r — an auto-trigger can't /say there. Bind a |cffffd700hotkey|r (a keypress is allowed) or use Party/Raid/Guild. Works fully on Classic.",
    yell     = "Send to /yell. |cffff5555On Retail/Midnight, Blizzard blocks AUTOMATIC public chat|r — an auto-trigger can't /yell there. Bind a |cffffd700hotkey|r (a keypress is allowed) or use Party/Raid/Guild. Works fully on Classic.",
    instance = "Send to instance chat (BG / dungeon / raid). Skipped silently when not in an instance group. Not a public channel, so auto-triggers can use it on Retail.",
    emote    = "Send as an /emote (e.g. \"%s coughs up blood\"). |cffff5555On Retail/Midnight, Blizzard blocks AUTOMATIC public chat|r — bind a |cffffd700hotkey|r to use it there. Works fully on Classic.",
}

-- ---------------------------------------------------------------------------
-- Line text <-> array helpers (one smack per line in the editor)
-- ---------------------------------------------------------------------------
local function splitLines(text)
    local t = {}
    if not text then return t end
    text = text:gsub("\r", "")
    for line in (text .. "\n"):gmatch("(.-)\n") do
        local s = strtrim and strtrim(line) or line
        if s ~= "" then t[#t + 1] = s end
    end
    return t
end

local function joinLines(lines)
    return table.concat(lines or {}, "\n")
end

-- ---------------------------------------------------------------------------
-- Form state (per session, not persisted)
-- ---------------------------------------------------------------------------
Tab._editing = Tab._editing or nil

local function workingCopy()
    if Tab._editing then return Tab._editing end
    Tab._editing = Tab:NewFilterDefaults()
    return Tab._editing
end

local function loadIntoForm(filter)
    local copy = {
        name     = filter.name or "",
        enabled  = filter.enabled ~= false,
        trigger  = filter.trigger or "self-hp-below",
        pct      = filter.pct or 35,
        lines    = {},
        channels = {},
        cooldown = filter.cooldown or 1,
        hotkey   = filter.hotkey or "",
        count    = filter.count or 0,
    }
    for _, l in ipairs(filter.lines or {}) do copy.lines[#copy.lines + 1] = l end
    if filter.channels then
        for k, v in pairs(filter.channels) do copy.channels[k] = v end
    end
    Tab._editing = copy
end

local function resetForm()
    Tab._editing = nil
end

-- ---------------------------------------------------------------------------
-- Cell formatters
-- ---------------------------------------------------------------------------
local function fmtTrigger(f)
    return TRIGGER_LABEL[f.trigger] or f.trigger or "?"
end

local function fmtPct(f)
    if PCT_TRIGGER[f.trigger] then return tostring(f.pct or 0) .. "%" end
    return "|cff888888—|r"
end

local function fmtChannels(f)
    if not f.channels then return "|cff888888none|r" end
    local on = {}
    for _, key in ipairs(addon.modules["smack"].CHANNEL_ORDER or {}) do
        if f.channels[key] then on[#on + 1] = CHANNEL_LABEL[key] or key end
    end
    if #on == 0 then return "|cff888888none|r" end
    return table.concat(on, ", ")
end

local function attachTip(widget, title, body)
    if widget and addon.GUI and addon.GUI.AttachTooltip then
        addon.GUI.AttachTooltip(widget, title, body)
    end
end

-- ---------------------------------------------------------------------------
-- Form — built once, re-populated on every Draw
-- ---------------------------------------------------------------------------
local function buildForm(parent, onSave, onDelete, onReset, rebuild)
    local Smack = addon.modules["smack"]
    local p = workingCopy()

    -- Row 1: Name, Trigger, Save, New, (Delete)
    local row1 = AceGUI:Create("SimpleGroup")
    row1:SetFullWidth(true)
    row1:SetLayout("Flow")
    parent:AddChild(row1)

    local nameInput = AceGUI:Create("EditBox")
    nameInput:SetLabel("Filter name")
    nameInput:SetWidth(200)
    nameInput:SetText(p.name or "")
    nameInput:SetCallback("OnEnterPressed", function(_, _, val)
        p.name = val
        nameInput:ClearFocus()
    end)
    nameInput:SetCallback("OnTextChanged", function(_, _, val) p.name = val end)
    row1:AddChild(nameInput)
    attachTip(nameInput, "Filter name",
        "Unique identifier for this filter. Saving with an existing name OVERWRITES that filter in place. A new name creates a new row in the list below.")

    local triggerDD = AceGUI:Create("Dropdown")
    triggerDD:SetLabel("Trigger")
    triggerDD:SetWidth(240)
    -- Only offer triggers that work on this flavour. On Retail the ones that
    -- read another unit's health (Target HP, Group member) are hidden — that
    -- data is secret to addons there. A Classic-made filter opened on Retail
    -- still shows its trigger (with a "(Classic only)" tag) so the form works.
    local tList, tOrder = {}, {}
    for _, k in ipairs(TRIGGER_ORDER) do
        local avail = Smack:IsTriggerAvailable(k)
        if avail or k == p.trigger then
            tOrder[#tOrder + 1] = k
            tList[k] = avail and TRIGGER_LABEL[k]
                or ((TRIGGER_LABEL[k] or k) .. " |cff888888(Classic only)|r")
        end
    end
    triggerDD:SetList(tList, tOrder)
    triggerDD:SetValue(p.trigger)
    triggerDD:SetCallback("OnValueChanged", function(_, _, key)
        p.trigger = key
        rebuild()   -- reshape the conditional % field
    end)
    row1:AddChild(triggerDD)
    attachTip(triggerDD, "Trigger",
        "Which combat moment makes this filter fire. Percent triggers (My HP, Target HP, Mana, Group member, Big hit) reveal a |cffffd700%|r field; the rest fire on the event. " ..
        (TRIGGER_TIP[p.trigger] and ("|cffffd700" .. (TRIGGER_LABEL[p.trigger] or "") .. ":|r " .. TRIGGER_TIP[p.trigger]) or ""))

    local saveBtn = AceGUI:Create("Button")
    saveBtn:SetWidth(80)
    saveBtn:SetText("Save")
    saveBtn:SetCallback("OnClick", function() onSave() end)
    row1:AddChild(saveBtn)
    attachTip(saveBtn, "Save",
        "Persist the form as a filter. New name = new row. Existing name = overwrite. A filter name is required.")

    local newBtn = AceGUI:Create("Button")
    newBtn:SetWidth(80)
    newBtn:SetText("New")
    newBtn:SetCallback("OnClick", function() onReset() end)
    row1:AddChild(newBtn)
    attachTip(newBtn, "New filter",
        "Reset the form to defaults so you can compose a fresh filter. Doesn't delete the loaded filter — just clears the form.")

    if p.name and p.name ~= "" and Smack:GetFilter(p.name) then
        local delBtn = AceGUI:Create("Button")
        delBtn:SetWidth(80)
        delBtn:SetText("Delete")
        delBtn:SetCallback("OnClick", function() onDelete(p.name) end)
        row1:AddChild(delBtn)
        attachTip(delBtn, "Delete filter",
            "Permanently remove the loaded filter. Only shown when the form holds an existing filter.")
    end

    -- Row 2: conditional % + cooldown
    local row2 = AceGUI:Create("SimpleGroup")
    row2:SetFullWidth(true)
    row2:SetLayout("Flow")
    parent:AddChild(row2)

    if PCT_TRIGGER[p.trigger] then
        local pctInput = AceGUI:Create("EditBox")
        pctInput:SetLabel(pctLabel(p.trigger))
        pctInput:SetWidth(110)
        pctInput:SetText(tostring(p.pct or 0))
        pctInput:SetCallback("OnEnterPressed", function(_, _, val)
            local n = tonumber(val) or 0
            if n < 1 then n = 1 elseif n > 100 then n = 100 end
            p.pct = n
            pctInput:SetText(tostring(p.pct))
            pctInput:ClearFocus()
        end)
        row2:AddChild(pctInput)
        attachTip(pctInput, pctLabel(p.trigger),
            (p.trigger == "self-bighit")
                and "A single hit at or above this percentage of your max health fires the filter. 1-100."
                or "The percentage the value must cross for this filter to fire. 1-100.")
    end

    local cdInput = AceGUI:Create("EditBox")
    cdInput:SetLabel("Every (min)")
    cdInput:SetWidth(110)
    cdInput:SetText(tostring(p.cooldown or 1))
    cdInput:SetCallback("OnEnterPressed", function(_, _, val)
        local n = tonumber(val) or 1
        if n < 1 then n = 1 end
        p.cooldown = math.floor(n)
        cdInput:SetText(tostring(p.cooldown))
        cdInput:ClearFocus()
    end)
    row2:AddChild(cdInput)
    attachTip(cdInput, "Every (minutes)",
        "Minimum gap between two firings of this filter, in minutes. Minimum 1. Stops one bad pull from dumping the whole line pool. Resets on /reload. (Doesn't apply to the hotkey — a deliberate press always fires, with just a 1-second anti-double-tap floor.)")

    -- Hotkey: an optional key combo to fire this filter on demand. Bound via
    -- SetOverrideBindingClick (reversible, never overwrites saved keybinds).
    local kb = AceGUI:Create("Keybinding")
    kb:SetLabel("Hotkey (optional)")
    kb:SetWidth(200)
    kb:SetKey(p.hotkey or "")
    kb:SetCallback("OnKeyChanged", function(_, _, key)
        p.hotkey = key or ""
        -- Conflict heads-up (don't block — the user picked it). GetBindingAction
        -- returns the user's SAVED binding for the key (we filter out our own
        -- override buttons so re-using a Smack key doesn't false-positive).
        if key and key ~= "" and GetBindingAction then
            local action = GetBindingAction(key)
            if action and action ~= "" and not tostring(action):find("TOGToolsSmackHotkeyBtn") then
                addon.lib:Print(string.format(
                    "|cffff8800Smack:|r |cffffd700%s|r is already bound to |cffffd700%s|r \226\128\148 your smack will override it while TOG Tools is loaded (the original comes back when you clear it or log out).",
                    key, tostring(action)))
            end
        end
    end)
    row2:AddChild(kb)
    attachTip(kb, "Hotkey (optional)",
        "Click, then press a key combo to fire this filter |cffffd700on demand|r. A keypress is a hardware event, so it works |cffffd700everywhere|r — including /say during a boss fight, where the automatic triggers can't. " ..
        "Bound non-permanently: your saved keybinds are never overwritten, and the binding clears on logout. If the key is already taken you'll get a heads-up in chat so you can pick a free one. Works on any trigger; pair it with the |cffffd700Manual (hotkey only)|r trigger for a pure on-demand line.")

    -- Row 3: channels
    local row3 = AceGUI:Create("SimpleGroup")
    row3:SetFullWidth(true)
    row3:SetLayout("Flow")
    parent:AddChild(row3)

    local chanLabel = AceGUI:Create("Label")
    chanLabel:SetWidth(70)
    chanLabel:SetText(addon.UI.Brand("Where:"))
    row3:AddChild(chanLabel)

    for _, key in ipairs(Smack.CHANNEL_ORDER) do
        local cb = AceGUI:Create("CheckBox")
        cb:SetWidth(80)
        cb:SetLabel(CHANNEL_LABEL[key])
        cb:SetValue(p.channels and p.channels[key] or false)
        cb:SetCallback("OnValueChanged", function(_, _, val)
            p.channels = p.channels or {}
            p.channels[key] = val and true or nil
        end)
        row3:AddChild(cb)
        attachTip(cb, CHANNEL_LABEL[key], CHANNEL_TIP[key])
    end

    -- Row 4: lines (one smack per row)
    local lineBox = AceGUI:Create("MultiLineEditBox")
    lineBox:SetLabel("Lines — one per row (placeholders: [me] [target] [hp] [targethp] [member])")
    lineBox:SetFullWidth(true)
    lineBox:SetNumLines(6)
    lineBox:SetText(joinLines(p.lines))
    lineBox:SetCallback("OnEnterPressed", function(_, _, val)
        p.lines = splitLines(val)
    end)
    lineBox:SetCallback("OnTextChanged", function(_, _, val)
        p.lines = splitLines(val)
    end)
    parent:AddChild(lineBox)
    attachTip(lineBox, "Lines",
        "Type one smack-talk line per row. One is chosen at random each time the filter fires. " ..
        "Blank rows are ignored. Click |cffffd700Okay|r (or press Ctrl-Enter) to commit your edits. " ..
        "Placeholders fill in at fire time: |cffffd700[me]|r your name, |cffffd700[target]|r your target's name, " ..
        "|cffffd700[hp]|r your current HP%, |cffffd700[targethp]|r the target's HP%, |cffffd700[member]|r the group member (for group triggers).")
end

-- ---------------------------------------------------------------------------
-- Draw
-- ---------------------------------------------------------------------------
function Tab:Draw(container)
    container:SetLayout("Flow")
    local Smack = addon.modules["smack"]

    local function rebuild()
        if addon.MainWindow and addon.MainWindow.Refresh then
            addon.MainWindow:Refresh()
        end
    end

    local function onSave()
        local p = workingCopy()
        if not p.name or p.name == "" then
            addon.lib:Print("|cffff5555Smack: filter name is required.|r")
            return
        end
        Smack:SaveFilter({
            name     = p.name,
            enabled  = p.enabled ~= false,
            trigger  = p.trigger,
            pct      = p.pct,
            lines    = p.lines or {},
            channels = p.channels or {},
            cooldown = p.cooldown or 1,
            hotkey   = p.hotkey or "",
            count    = p.count or 0,
        })
        resetForm()
        rebuild()
    end

    local function onDelete(name)
        Smack:DeleteFilter(name)
        resetForm()
        rebuild()
    end

    local function onReset()
        resetForm()
        rebuild()
    end

    buildForm(container, onSave, onDelete, onReset, rebuild)

    local sp = AceGUI:Create("Label")
    sp:SetFullWidth(true); sp:SetText(" ")
    container:AddChild(sp)

    local body = AceGUI:Create("SimpleGroup")
    body:SetFullWidth(true)
    body:SetFullHeight(true)
    body:SetLayout("List")
    container:AddChild(body)

    local filters = Smack:GetFilters()
    if #filters == 0 then
        local empty = AceGUI:Create("Label")
        empty:SetFullWidth(true)
        empty:SetText("|cff888888No Smack filters yet.|r\n\nFill in the form above and click |cffffd700Save|r to create your first one. Pick a trigger, set the |cffffd700%|r (if shown), choose where to say it, type a few lines, and tick |cffffd700On|r in the list to activate it. Everything fires only in combat.")
        body:AddChild(empty)
        return
    end

    local rl = addon.RowList:New(body.content, {
        rowHeight = 18,
        columns = {
            { key = "enabled", header = "On", width = 30, justify = "CENTER",
              checkbox = true, boxSize = 18,
              headerTip = "On",
              headerTipDesc = "Tick to activate this filter. Disabled filters stay in the list but never fire. Toggling here saves immediately.",
              onToggle = function(entry, val)
                  entry.enabled = val
                  addon.modules["smack"]:SaveFilter(entry)
              end },

            -- No width/autoFit → this is the auto-width "stretch" column (one
            -- per RowList), anchored LEFT like AddonLoad's Addon Name column.
            -- Without it every column has a width and RowList right-packs them
            -- all; with it the table spans from the left like the other tabs.
            { key = "name", header = "Name", justify = "LEFT",
              headerTip = "Name",
              headerTipDesc = "Filter identifier. Click the row to load it into the form above for editing.",
              format = function(f) return f.name or "?" end },

            { key = "trigger", header = "Trigger", autoFit = true, minWidth = 90, justify = "LEFT",
              headerTip = "Trigger",
              headerTipDesc = "What combat moment the filter reacts to.",
              format = function(f) return fmtTrigger(f) end },

            { key = "pct", header = "%", width = 44, justify = "RIGHT",
              headerTip = "Threshold",
              headerTipDesc = "The percent threshold, for percent-based triggers. Event triggers show a dash.",
              format = function(f) return fmtPct(f) end },

            { key = "channels", header = "Where", autoFit = true, minWidth = 70, justify = "LEFT",
              headerTip = "Where",
              headerTipDesc = "Chat destinations the line fires on. Channels you're not connected to are skipped at fire time.",
              format = function(f) return fmtChannels(f) end },

            { key = "hotkey", header = "Key", autoFit = true, minWidth = 40, justify = "LEFT",
              headerTip = "Hotkey",
              headerTipDesc = "The key combo that fires this filter on demand, if set. Bound non-permanently (never overwrites your saved keybinds).",
              format = function(f) return (f.hotkey and f.hotkey ~= "") and f.hotkey or "|cff888888—|r" end },

            { key = "count", header = "Count", autoFit = true, minWidth = 40, justify = "RIGHT",
              headerTip = "Count",
              headerTipDesc = "Total times this filter has fired since creation.",
              sortDescDefault = true,
              format = function(f) return tostring(f.count or 0) end },
        },
        onRowClick = function(f)
            loadIntoForm(f)
            rebuild()
        end,
    })
    rl:SetSort("name", false)
    rl:SetData(filters)

    body:SetCallback("OnRelease", function() rl:Detach() end)
end
