-- DopingControl ui/options.lua
-- Standalone options frame,
-- built with proven 1.12 options-window patterns
-- : lazy build on first Toggle, layout cursor, widget factories with
-- getglobal labels, GetChecked() == 1, save-and-chain tooltips (no
-- HookScript on 1.12), OnShow repopulation from DB, ESC via
-- UISpecialFrames, pfUI skin hooks. (No sliders needed here; the
-- SetMinMaxValues-before-SetValue rule therefore has no call site.)
--
-- Contents, top to bottom:
--   title "DopingControl"
--   [x] Test mode (simulated raid)   -> DC.SetTestMode(v)  (core/init.lua)
--   [x] Show minimap button          -> DC_Minimap.Update() (features/minimap.lua)
--   [x] Auto-scan on ready check     -> db.readyCheckScan
--   [x] Auto-open on ready check     -> db.readyCheckOpen
--   divider
--   Editor: a tab selector (Consumables / Class buffs / Equipment /
--     Skill books, O.EditorTabs()) picks ONE of two mutually exclusive
--     content blocks that share the same position and width -- only one
--     is ever shown (ApplyEditorTabVisibility), so the window height is
--     fixed at build time to whichever block is taller and never jumps
--     when the tab changes:
--     - the three expectation tabs (O.ExpectationTabs(), expectations are
--       PER ROLE x CLASS) show a GROUPED grid inside a scroll frame (max
--       height O.GRID_MAX_H): per role one HEADER row (role name, role
--       color; clicking a header cell bulk-toggles that slot for ALL
--       performing classes of the role -- majority flip, O.BulkNewValue)
--       and beneath it one indented row per performing class (class name
--       in class color, own checkboxes on db.expectations[role][class]
--       [slotId]). Row order = O.GridRows: 25 rows (TANK 1+4, MELEE 1+6,
--       RANGED 1+1, CASTER 1+5, HEALER 1+4), identical for all three
--       tabs. Checkbox cells are POOLED (lazy creation, reuse per tab
--       switch, surplus hidden -- up to 25x16 on equipment). A click
--       applies to the matrix IMMEDIATELY. The editor always shows ALL
--       slots of the tab -- dynamic columns apply to the matrix, not here
--       (a fully disabled slot must stay re-enableable). Role header
--       cells have no tri-state on 1.12: all classes checked = checked,
--       none = unchecked, mixed = checked at 0.45 alpha. The
--       "Reset expectations" button (double-click-to-confirm --
--       StaticPopup is 1.12-fiddly: first click arms ("Click again to
--       confirm"), second click within 5 s reseeds every class line from
--       DC.DEFAULT_EXPECT via DC.ResetExpectations; the label reverts
--       automatically when the window lapses) belongs to this block.
--     - the Skill books tab (O.IsSkillBookTab) shows the SAME KIND OF
--       GRID as the expectation tabs, so both read as one table: sticky
--       3-character column headers, one row per scanned player, a
--       checkbox at the crossing. The columns are the FIXED weapon-skill
--       set (O.BOOK_COLUMNS, 15), never the per-player skill list -- the
--       old layout wrapped each player's skills across up to 5 ragged
--       lines with the label next to every box, which made the tab
--       unreadable at raid size. Cell state comes from
--       O.BookCellState: worn = full strength (the common tick),
--       trainable = dimmed, not trainable = no checkbox at all. A player
--       whose class is unknown gets a hint line instead of cells. A tick
--       writes db.skillBooks[player][skill] = true (an untick writes
--       explicit false) and refreshes the matrix at once. Rows come from
--       the store and change with every scan, so the pool is rebuilt on
--       OnShow/Refresh; an empty store shows a hint line instead. Fixed
--       scroll height (O.BOOKS_MAX_H) -- the roster length never resizes
--       the window. The explanation line ("A book is worth about 0.6%
--       hit...") sits at the top of this block.
--   version footer from GetAddOnMetadata
--
-- Cross-module calls (DC.SetTestMode, DC_Minimap,
-- DC_Matrix, DC_Roles.ROLE_CLASSES, DC.ResetExpectations) are
-- existence-guarded so a partially loaded addon never hard-errors here;
-- O.RoleClasses falls back to inverting
-- DC_Roles.CLASS_ROLES when ROLE_CLASSES is absent.
-- Dofile-loadable under pure Lua 5.0 -- all WoW wiring behind CreateFrame;
-- the pure grid helpers at the top are offline-tested.

DopingControl = DopingControl or {}
local DC = DopingControl

DC_Options = DC_Options or {}
local O = DC_Options

-- seconds the "Reset expectations" confirmation stays armed
O.RESET_ARM_WINDOW = 5

-- The expectation grid and the weapon-skill-book list are separate TABS
-- of the same editor now and never show at once (ApplyEditorTabVisibility
-- picks one), so they no longer share a combined height budget -- each
-- gets its own scroll cap. The window height is fixed at BUILD time to
-- the taller of the two tab contents (see BuildOptionsFrame), so
-- switching to the Skill books tab never resizes the window; giving the
-- book list the SAME cap as the grid keeps that taller side unchanged and
-- avoids a large empty gap under the shorter tab.
O.GRID_MAX_H = 240

-- weapon-skill-book section: FIXED scroll height (the roster length must
-- never resize the window), same cap as the grid for the reason above.
O.BOOKS_MAX_H = O.GRID_MAX_H

-- ------------------------------------------------------------------
-- O.BOOK_COLUMNS -- the FIXED column set of the Skill books grid, in
-- display order: one column per weapon skill that exists on this client,
-- grouped one-hand melee / two-hand melee / ranged. Same shape as the
-- equipment slot table (data/enchants.lua): `label` is the 3-character
-- column header, `full` the tooltip word, `skill` the write key into
-- db.skillBooks (and the exact string DC.CLASS_WEAPON_SKILLS uses).
--
-- The column set is FIXED, never derived from the current roster -- the
-- same reason the expectation editor always shows all slots of its tab:
-- columns that appear and disappear with the scan make the grid unreadable
-- and move every tick target. A cell is simply hidden on a row whose class
-- cannot train that skill (O.SkillBookRows' `has`).
--
-- This list is a literal on purpose (no dependency on data/hit.lua's load
-- order); test_options_grid pins it against DC.WEAPON_SKILL_OF_SUBTYPE's
-- value set and against every DC.CLASS_WEAPON_SKILLS entry, so a skill
-- added there can never silently lose its column.
-- ------------------------------------------------------------------
O.BOOK_COLUMNS = {
    { skill = "Axes",              label = "AXE", sub = "1h",   full = "Axes" },
    { skill = "Maces",             label = "MAC", sub = "1h",   full = "Maces" },
    { skill = "Swords",            label = "SWD", sub = "1h",   full = "Swords" },
    { skill = "Daggers",           label = "DAG", sub = "1h",   full = "Daggers" },
    { skill = "Unarmed",           label = "UNA", sub = "fist", full = "Unarmed (fist weapons)" },
    { skill = "Two-Handed Axes",   label = "2HA", sub = "2h",   full = "Two-Handed Axes" },
    { skill = "Two-Handed Maces",  label = "2HM", sub = "2h",   full = "Two-Handed Maces" },
    { skill = "Two-Handed Swords", label = "2HS", sub = "2h",   full = "Two-Handed Swords" },
    { skill = "Polearms",          label = "POL", sub = "2h",   full = "Polearms" },
    { skill = "Staves",            label = "STV", sub = "2h",   full = "Staves" },
    { skill = "Bows",              label = "BOW", sub = "rng",  full = "Bows" },
    { skill = "Crossbows",         label = "XBW", sub = "rng",  full = "Crossbows" },
    { skill = "Guns",              label = "GUN", sub = "rng",  full = "Guns" },
    { skill = "Thrown",            label = "THR", sub = "rng",  full = "Thrown" },
    { skill = "Wands",             label = "WND", sub = "rng",  full = "Wands" },
}

-- ------------------------------------------------------------------
-- Shared grid geometry for BOTH editor grids. The column pitch and the
-- header-tile size are deliberately the MAIN WINDOW's numbers
-- (MX.CELL_W 30 + MX.CELL_GAP 3, MX header tile 26 high with a 9 px label
-- over a 7 px sub) so the options tables read as the same furniture as
-- the matrix instead of as a second, denser style. They are repeated here
-- rather than read from DC_Matrix: ui/matrix.lua may not be loaded when
-- this file is dofile'd offline, and a silent nil would collapse the
-- layout to zero-width columns.
-- ------------------------------------------------------------------
O.CELL_W     = 30   -- = MX.CELL_W
O.CELL_GAP   = 3    -- = MX.CELL_GAP
O.COL_PITCH  = 33   -- CELL_W + CELL_GAP, both grids
O.HEAD_H     = 26   -- header tile height (= MX header tile: HEADER_H - 4)
O.HEAD_ROW_H = 30   -- header bar height (= MX.HEADER_H)

O.GRID_LABEL_W = 74   -- role/class label column (expectation grid)
O.BOOK_NAME_W  = 96   -- player-name column (skill book grid)
O.ROW_H        = 22   -- one grid row's height, both grids

-- consumables (v6 slot model) has the most configurable slots (22 incl.
-- the five school protection columns);
-- equipment follows with 14 (11 armor enchants + neck + 2 rings --
-- trinkets are item tiles and never configurable, see slotsForTab).
-- The widest grid the pools have to serve.
O.MAX_SLOT_COLS = 22

-- x-offset of the 20 px check button inside its O.CELL_W-wide column
O.CELL_INSET = math.floor((O.CELL_W - 20) / 2)

-- ⚠ Lua 5.0 allows a function at most 32 UPVALUES, and BuildOptionsFrame
-- in the WoW section below sits right at that ceiling. Every geometry
-- number therefore lives HERE, on the O table (one upvalue), instead of
-- as its own file-local in that block -- twelve locals cost twelve
-- upvalues and overflowing is a COMPILE error that stops the whole file
-- from loading, i.e. the addon silently loses its options window.

-- content width of either grid: label column + one pitch per column.
function O.GridContentW(nCols)
    return O.GRID_LABEL_W + (nCols or 0) * O.COL_PITCH
end

-- ------------------------------------------------------------------
-- Window width follows the CONTENT of the selected tab, the way the main
-- window follows its own column count -- a window sized once to its widest
-- tab leaves the narrow ones (class buffs: 6 columns) swimming in empty
-- space.
--
-- The floor is not a taste number: the editor's own tab BUTTON ROW has to
-- fit, so it is derived from the button geometry and the tab count. Add a
-- fifth tab and the floor grows by itself.
-- ------------------------------------------------------------------
O.FRAME_PAD_L   = 24
O.FRAME_PAD_R   = 24
O.SCROLLBAR_W   = 22   -- UIPanelScrollFrameTemplate's bar, right of the scroll
O.TAB_BTN_W     = 108
O.TAB_BTN_PITCH = 114

function O.MinFrameW()
    local n = table.getn(O.EditorTabs())
    if n < 1 then
        n = 1
    end
    return O.FRAME_PAD_L + (n - 1) * O.TAB_BTN_PITCH + O.TAB_BTN_W
        + O.FRAME_PAD_R
end

function O.FrameWidthFor(contentW)
    local w = O.FRAME_PAD_L + (contentW or 0) + O.SCROLLBAR_W + O.FRAME_PAD_R
    local min = O.MinFrameW()
    if w < min then
        w = min
    end
    return w
end

-- total content width of the book section (name column + all skill
-- columns); the options frame is sized to fit this (see widestFrameW()).
function O.BooksContentW()
    return O.BOOK_NAME_W + table.getn(O.BOOK_COLUMNS) * O.COL_PITCH
end

-- shown instead of checkboxes for a player whose class could not be
-- determined -- weapon skills are derived from the class
-- (DC.CLASS_WEAPON_SKILLS), so without a class there is nothing to offer.
O.SKILLBOOK_UNREADABLE_HINT = "class unknown - can't tell weapon skills"

-- ==================================================================
-- PURE SECTION -- grid row model (offline-testable)
-- ==================================================================

-- Class token order for role lines (WARRIOR,
-- PALADIN, HUNTER, ROGUE, PRIEST, SHAMAN, MAGE, WARLOCK, DRUID, filtered
-- per role). Used by the ROLE_CLASSES fallback inversion below.
O.CLASS_ORDER = { "WARRIOR", "PALADIN", "HUNTER", "ROGUE", "PRIEST",
                  "SHAMAN", "MAGE", "WARLOCK", "DRUID" }

-- Performing classes of a role, ordered. Prefers DC_Roles.ROLE_CLASSES
-- (core/roles.lua); when it is absent,
-- derives the same lists by inverting DC_Roles.CLASS_ROLES in
-- O.CLASS_ORDER. Resolved at CALL time, never cached across files.
function O.RoleClasses(role)
    if DC_Roles and DC_Roles.ROLE_CLASSES and DC_Roles.ROLE_CLASSES[role] then
        return DC_Roles.ROLE_CLASSES[role]
    end
    local out = {}
    local cr = DC_Roles and DC_Roles.CLASS_ROLES
    if not cr then
        return out
    end
    for i = 1, table.getn(O.CLASS_ORDER) do
        local class = O.CLASS_ORDER[i]
        local list = cr[class]
        if list then
            for j = 1, table.getn(list) do
                if list[j] == role then
                    table.insert(out, class)
                end
            end
        end
    end
    return out
end

-- Ordered grid row descriptors for the expectation editor:
-- { kind = "role"|"class", role = <token>, class = <token|nil> }.
-- Identical for all three expectation tabs (the tab only changes the
-- COLUMNS); the parameter is kept for signature symmetry. 25 rows total:
-- TANK 1+4, MELEE 1+6, RANGED 1+1, CASTER 1+5, HEALER 1+4.
function O.GridRows(tab)
    local rows = {}
    local nR = table.getn(DC.ROLES)
    for i = 1, nR do
        local role = DC.ROLES[i]
        table.insert(rows, { kind = "role", role = role })
        local classes = O.RoleClasses(role)
        for j = 1, table.getn(classes) do
            table.insert(rows, { kind = "class", role = role,
                                 class = classes[j] })
        end
    end
    return rows
end

-- Bulk toggle target for a role header cell click: the new value is the
-- flip of the current majority. Strict majority checked -> uncheck all;
-- majority unchecked OR tie -> check all.
function O.BulkNewValue(checkedCount, total)
    return not (checkedCount * 2 > total)
end

-- ------------------------------------------------------------------
-- Weapon skill books (+3). A quest book raises a weapon skill by 3 --
-- worth roughly 0.6% hit -- and there is NO way to read it off another
-- player (no skill channel exists for foreign units). So it is entered
-- by hand here, per player and per weapon skill, and the scan folds it
-- into that player's assumed weapon skill on the next read.
--
-- A book applies no matter what is currently in the player's hand (a
-- weapon swap or a spare off-hand does not un-learn a skill), so every
-- skill the player's CLASS can plausibly train is offered -- not just
-- whatever happens to be equipped at scan time. The currently WORN
-- skills are still called out (sorted first, rendered brighter): that is
-- the common case a raid leader wants to tick without hunting through the
-- full class list.
-- ------------------------------------------------------------------

-- Rows for the book section: { name, class, skills = { <skill>, ... },
-- wornCount = <n>, has = { [skill] = true }, worn = { [skill] = true },
-- unreadable = <bool> }.
--
-- `has`/`worn` are the render keys of the GRID (one fixed column per
-- weapon skill, O.BOOK_COLUMNS): `has` decides whether the row shows a
-- checkbox in that column at all, `worn` whether it renders at full
-- strength. They carry exactly the same information as the ordered
-- `skills` array below -- which the grid no longer needs for placement,
-- but which stays because it is the readable form of "what is this player
-- offered", and because worn-first order is what a per-row tooltip and any
-- future list rendering would use.
-- `skills` is the UNION of (a) every weapon skill DC.CLASS_WEAPON_SKILLS
-- lists for the player's class and (b) whatever the scan currently sees
-- equipped (subtype -> skill via DC.WEAPON_SKILL_OF_SUBTYPE,
-- data/hit.lua). Order: the CURRENTLY WORN skills first (`skills`
-- 1..wornCount, deduplicated -- both hands on the same skill line count
-- once), then the rest of the class list in ITS declared order. A worn
-- skill outside the class list (server-specific exception) still rides
-- along in the worn block -- see the header of DC.CLASS_WEAPON_SKILLS.
-- A player whose CLASS is unknown gets `unreadable = true` and an empty
-- `skills` -- still a row (the roster line is never silently dropped),
-- rendered as a hint instead of checkboxes (O.SKILLBOOK_UNREADABLE_HINT).
-- Players sorted by name (deterministic across scans, like every other
-- list in this addon).
function O.SkillBookRows(store)
    local rows = {}
    local players = (store and store.players) or {}
    local subMap = DC.WEAPON_SKILL_OF_SUBTYPE or {}
    local classSkills = DC.CLASS_WEAPON_SKILLS or {}
    for i = 1, table.getn(players) do
        local p = players[i]
        local class = p.class
        if type(class) == "string" and class ~= "" then
            local seen, skills, worn = {}, {}, {}
            local h = p.hit
            if type(h) == "table" then
                local subs = { h.mainSub, h.offSub }
                for s = 1, 2 do
                    local sub = subs[s]
                    local skill = (type(sub) == "string") and subMap[sub]
                        or nil
                    if skill and not seen[skill] then
                        seen[skill] = true
                        worn[skill] = true
                        table.insert(skills, skill)
                    end
                end
            end
            local wornCount = table.getn(skills)
            local cls = classSkills[class] or {}
            for c = 1, table.getn(cls) do
                local skill = cls[c]
                if not seen[skill] then
                    seen[skill] = true
                    table.insert(skills, skill)
                end
            end
            table.insert(rows, { name = p.name, class = class,
                                 skills = skills, wornCount = wornCount,
                                 has = seen, worn = worn })
        else
            table.insert(rows, { name = p.name, class = class,
                                 skills = {}, wornCount = 0,
                                 has = {}, worn = {},
                                 unreadable = true })
        end
    end
    table.sort(rows, function(a, b) return a.name < b.name end)
    return rows
end

-- Cell state for (row, column) in the Skill books grid:
--   "worn"   -- the player is holding a weapon on this skill line right
--              now: the common tick, rendered at full strength
--   "train"  -- the player's class can train it but nothing on that line
--              is equipped: still tickable, rendered dimmed
--   nil      -- this class cannot train it (and it is not worn): no
--              checkbox at all, so the eye only ever meets cells that can
--              actually carry a book
-- A row with an unknown class has no cells at all (hint line instead).
function O.BookCellState(row, skill)
    if not row or row.unreadable or type(skill) ~= "string" then
        return nil
    end
    if row.worn and row.worn[skill] then
        return "worn"
    end
    if row.has and row.has[skill] then
        return "train"
    end
    return nil
end

-- Empty-state line for the book section, nil once rows exist. Rows now
-- need only a known CLASS (see O.SkillBookRows), so this only fires for a
-- genuinely empty roster -- no scan has happened yet.
function O.SkillBookHint(rows)
    if rows and table.getn(rows) > 0 then
        return nil
    end
    return "No scanned players yet - scan the raid first."
end

-- Saved shape: db.skillBooks[playerName][skillName] = true|false.
function O.SkillBookGet(db, name, skill)
    local books = db and db.skillBooks
    local line = books and books[name]
    return (line and line[skill]) and true or false
end

-- Unticking writes an explicit FALSE, never nil -- a bare nil would let a
-- later defaults top-up re-create the entry and silently re-tick a book
-- the user deliberately removed (same rule as the expectation grid).
function O.SkillBookSet(db, name, skill, value)
    if not db or type(name) ~= "string" or type(skill) ~= "string" then
        return
    end
    if db.skillBooks == nil then
        db.skillBooks = {}
    end
    if type(db.skillBooks[name]) ~= "table" then
        db.skillBooks[name] = {}
    end
    db.skillBooks[name][skill] = value and true or false
end

-- ------------------------------------------------------------------
-- Tabs the expectation editor offers.
-- A tab that never CONSULTS expectations must never appear here, or the
-- grid would let the user tick boxes that change nothing: DEBUFFS (every
-- column applies to every player), RESIST and HIT (display-only tabs --
-- their columns are always all active, see core/model.lua). The list is
-- derived from DC.TABS by exclusion rather than by index, so a new tab
-- shifting positions cannot silently drag a display tab into the editor.
-- ------------------------------------------------------------------
O.NO_EXPECTATION_TABS = {
    DEBUFFS = true,
    RESIST  = true,
    HIT     = true,
}

function O.ExpectationTabs()
    local out = {}
    local src = (DC and DC.TABS)
        or { "CONSUMABLES", "CLASSBUFFS", "EQUIPMENT" }
    for i = 1, table.getn(src) do
        if not O.NO_EXPECTATION_TABS[src[i]] then
            table.insert(out, src[i])
        end
    end
    return out
end

-- ------------------------------------------------------------------
-- The editor's FULL tab order: the three expectation tabs plus the
-- weapon-skill-book tab last. A SEPARATE list from O.ExpectationTabs() on
-- purpose -- SKILLBOOKS has no expectation grid (O.IsSkillBookTab), so any
-- code that walks "tabs with a grid" must keep using O.ExpectationTabs()
-- and never this one.
-- ------------------------------------------------------------------
O.SKILLBOOK_TAB = "SKILLBOOKS"
O.SKILLBOOK_TAB_LABEL = "Skill books"

function O.EditorTabs()
    local out = O.ExpectationTabs()
    table.insert(out, O.SKILLBOOK_TAB)
    return out
end

-- True for the one editor tab that swaps the expectation grid for the
-- weapon-skill-book list (see the WoW section: RefreshEditor /
-- ApplyEditorTabVisibility).
function O.IsSkillBookTab(tab)
    return tab == O.SKILLBOOK_TAB
end

-- Button label for an editor tab: DC.TAB_LABEL for the three real tabs,
-- O.SKILLBOOK_TAB_LABEL for the pseudo-tab that only exists in this editor.
function O.EditorTabLabel(tab)
    if O.IsSkillBookTab(tab) then
        return O.SKILLBOOK_TAB_LABEL
    end
    return (DC.TAB_LABEL and DC.TAB_LABEL[tab]) or tab
end

-- Class token -> display word ("WARRIOR" -> "Warrior") for class row labels.
function O.ClassTitle(class)
    if type(class) ~= "string" or class == "" then
        return "?"
    end
    return string.upper(string.sub(class, 1, 1))
        .. string.lower(string.sub(class, 2))
end

-- Label of the demo-mode button. Demo mode is TRANSIENT (core/demo.lua:
-- never SavedVariables), so it gets a self-relabeling button rather than a
-- checkbox -- the checkbox row above is bound to db fields, and a checkbox
-- whose state dies on reload would read like a broken setting.
function O.DemoButtonLabel()
    if DC.demoMode then
        return "Demo mode: ON (click to stop)"
    end
    return "Demo mode: OFF (click to start)"
end

-- ==================================================================
-- WOW SECTION -- frame, widgets, wiring
-- ==================================================================
if CreateFrame then

    local frame = nil            -- DopingControlOptionsFrame (lazy)
    local checkboxes = {}        -- top checkboxes, for OnShow repopulate
    local expTab = "CONSUMABLES" -- editor tab (not persisted)

    -- ⚠ Geometry deliberately has NO file-locals here (see the upvalue
    -- note at O.MAX_SLOT_COLS): everything is read off O, which every
    -- function in this block already captures as a single upvalue.
    --   O.GRID_LABEL_W  label column          O.COL_PITCH  column pitch
    --   O.BOOK_NAME_W   player-name column    O.ROW_H      row pitch
    --   O.CELL_INSET    check button inset    O.MAX_SLOT_COLS widest grid
    -- The two grids differ in column count (22 consumable slots vs. 15
    -- weapon skills); the frame is BUILT at the wider of the two and
    -- narrowed per tab by ApplyEditorWidth, so no widget is ever created
    -- against a width it later has to grow past.
    local function gridWidest()
        return O.GridContentW(O.MAX_SLOT_COLS)
    end
    local function widestFrameW()
        local c = gridWidest()
        if O.BooksContentW() > c then
            c = O.BooksContentW()
        end
        return O.FrameWidthFor(c)
    end

    -- grid pools (lazy creation, never destroyed, surplus hidden)
    local gridChild = nil        -- scroll child, parent of labels + cells
    local gridRowMax = 0         -- highest row index ever created
    local gridColMax = {}        -- [r] = highest col index created for row r
    local gridZebra = {}         -- [r] = alternating-row wash texture
    local eqHintFS = nil         -- equipment-only dim note (RefreshGrid sets text/visibility)
    local dividerTex = nil       -- head divider, width follows the frame
    local RefreshGrid            -- forward declaration (click handlers call it)

    -- weapon-skill-book section: same pooling pattern as the expectation
    -- grid, and now the same geometry -- the pool addresses PLAYER ROWS x
    -- the fixed O.BOOK_COLUMNS. Unlike the expectation grid the ROW
    -- CONTENT comes from the STORE and changes with every scan, so the
    -- pool is refilled on OnShow/Refresh (the row COUNT is what varies,
    -- never the columns).
    local booksChild = nil       -- scroll child, parent of name labels + cells
    local booksRowMax = 0
    local booksColMax = {}       -- [r] = highest col index created for row r
    local booksZebra = {}        -- [r] = alternating-row wash texture
    local booksHintFS = nil      -- empty-state line (O.SkillBookHint)
    local RefreshBooks           -- forward declaration

    -- Full editor tab order -- resolved by the pure O.EditorTabs() above
    -- (the three expectation tabs, DEBUFFS/RESIST/HIT excluded there,
    -- plus SKILLBOOKS last).
    local EDITOR_TABS = O.EditorTabs()

    local function db()
        return DopingControlDB
    end

    local function slotsForTab(tab)
        if tab == O.SKILLBOOK_TAB then
            -- no expectation grid on this tab (see O.IsSkillBookTab); the
            -- caller hides the grid block entirely, this is defensive
            return {}
        elseif tab == "CLASSBUFFS" then
            return DC.SLOTS_CLASSBUFFS or {}
        elseif tab == "EQUIPMENT" then
            -- only kind=="ench" slots are configurable here (15: the 11
            -- armor slots + neck/rings/waist, which are enchantable on
            -- this server -- data/enchants.lua). T1/T2 (trinket item tiles)
            -- have no enchant question to ask and never appear: the
            -- matrix always shows them (item present/absent decides,
            -- expectations are never consulted for tiles).
            local all = DC.SLOTS_EQUIPMENT or {}
            local out = {}
            for i = 1, table.getn(all) do
                if all[i].kind == "ench" then
                    table.insert(out, all[i])
                end
            end
            return out
        end
        return DC.SLOTS_CONSUMABLES or {}
    end

    -- expectations table (shape: [role][class][slotId] = true|nil),
    -- created on demand (config.EnsureDefaults normally materialized +
    -- migrated this at ADDON_LOADED; defensive for odd load orders)
    local function expectations()
        local d = db()
        if not d.expectations then
            local t = {}
            for i = 1, table.getn(DC.ROLES) do
                local role = DC.ROLES[i]
                t[role] = {}
                local classes = O.RoleClasses(role)
                local def = (DC.DEFAULT_EXPECT and DC.DEFAULT_EXPECT[role])
                    or {}
                for j = 1, table.getn(classes) do
                    local line = {}
                    for k, v in pairs(def) do
                        if v then
                            line[k] = true
                        end
                    end
                    t[role][classes[j]] = line
                end
            end
            d.expectations = t
        end
        return d.expectations
    end

    -- Effective expectation line for (role, class): the explicit class
    -- line when present, else the DC.DEFAULT_EXPECT role line (rule-5
    -- runtime fallback -- same rule the model applies). Old-shape saved
    -- tables (booleans directly under [role]) are migrated by
    -- config.EnsureDefaults; here they simply fall back to the default.
    local function effLine(exp, role, class)
        local r = exp[role]
        if r and type(r[class]) == "table" then
            return r[class]
        end
        return (DC.DEFAULT_EXPECT and DC.DEFAULT_EXPECT[role]) or {}
    end

    -- Materialize the class line before the FIRST write: copy the
    -- effective line, so toggling one slot never silently drops the
    -- fallback values of the other slots (the model treats a present
    -- class line as authoritative).
    local function materializeLine(exp, role, class)
        exp[role] = exp[role] or {}
        if type(exp[role][class]) ~= "table" then
            local copy = {}
            local src = effLine(exp, role, class)
            for k, v in pairs(src) do
                if v then
                    copy[k] = true
                end
            end
            exp[role][class] = copy
        end
        return exp[role][class]
    end

    local function refreshMatrix()
        if DC_Matrix and DC_Matrix.Refresh then
            DC_Matrix.Refresh()
        end
    end

    local function panelBackdrop()
        if DC_Widgets and DC_Widgets.PANEL_BACKDROP then
            return DC_Widgets.PANEL_BACKDROP
        end
        if DC.PANEL_BACKDROP then
            return DC.PANEL_BACKDROP
        end
        return {
            bgFile = "Interface\\AddOns\\DopingControl\\textures\\panel_bg",
            edgeFile = "Interface\\AddOns\\DopingControl\\textures\\panel_border",
            tile = true, tileSize = 128, edgeSize = 16,
            insets = { left = 5, right = 5, top = 5, bottom = 5 },
        }
    end

    -- ----------------------------------------------------------------
    -- widget helpers
    -- ----------------------------------------------------------------
    local function ApplyFont(fs, size)
        if pfUI and pfUI.font_default and pfUI_config and pfUI_config.global
           and pfUI_config.global.font_size then
            fs:SetFont(pfUI.font_default, pfUI_config.global.font_size)
        elseif size then
            fs:SetFont("Fonts\\FRIZQT__.TTF", size)
        end
    end

    -- save-and-chain tooltip (no HookScript on 1.12)
    local function AddTooltip(widget, text)
        local oldEnter = widget:GetScript("OnEnter")
        local oldLeave = widget:GetScript("OnLeave")
        widget.dcTooltip = text
        widget:SetScript("OnEnter", function()
            if oldEnter then oldEnter() end
            GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
            GameTooltip:SetText(this.dcTooltip, 1, 1, 1, 1, 1)
            GameTooltip:Show()
        end)
        widget:SetScript("OnLeave", function()
            if oldLeave then oldLeave() end
            GameTooltip:Hide()
        end)
    end

    local checkCounter = 0
    local function CreateCheckbox(parent, label, getter, setter)
        checkCounter = checkCounter + 1
        local name = "DopingControlOptCheck" .. checkCounter
        local cb = CreateFrame("CheckButton", name, parent,
            "UICheckButtonTemplate")
        cb:SetWidth(24)
        cb:SetHeight(24)
        local text = getglobal(name .. "Text")
        text:SetText(label)
        ApplyFont(text, 11)
        cb.dcGet = getter
        cb.dcSet = setter
        cb:SetScript("OnClick", function()
            this.dcSet(this:GetChecked() == 1)  -- GetChecked returns 1|nil
        end)
        if pfUI and pfUI.api and pfUI.api.SkinCheckbox then
            pfUI.api.SkinCheckbox(cb)
        end
        table.insert(checkboxes, cb)
        return cb
    end

    local function CreateButton(parent, name, label, width, onClick)
        local b = CreateFrame("Button", name, parent, "UIPanelButtonTemplate")
        b:SetWidth(width or 140)
        b:SetHeight(22)
        b:SetText(label)
        b:SetScript("OnClick", onClick)
        if pfUI and pfUI.api and pfUI.api.SkinButton then
            pfUI.api.SkinButton(b)
        end
        return b
    end

    -- ----------------------------------------------------------------
    -- column-header furniture, deliberately identical to the MAIN
    -- window's header row (ui/matrix.lua: header bar with theadBg/goldSoft,
    -- one panel2/line tile per column, a 9 px label over a 7 px sub-line).
    -- The two windows are meant to read as one tool -- bare grey text over
    -- the grid did not.
    --
    -- These use their OWN font helper: the file-local ApplyFont takes
    -- pfUI's GLOBAL font size when pfUI is loaded, which is right for
    -- flowing option text but would blow a 7 px sub-line out of a 26 px
    -- tile. ApplyFontFixed keeps our pixel size and only borrows pfUI's
    -- font family -- exactly what DC_Widgets.ApplyFont does for the matrix.
    -- ----------------------------------------------------------------
    local function ApplyFontFixed(fs, size)
        if DC_Widgets and DC_Widgets.ApplyFont then
            DC_Widgets.ApplyFont(fs, size)
        elseif pfUI and pfUI.font_default then
            fs:SetFont(pfUI.font_default, size)
        else
            fs:SetFont("Fonts\\FRIZQT__.TTF", size)
        end
    end

    local function colorOf(key, r, g, b)
        local c = DC.COLORS and DC.COLORS[key]
        if c then
            return c
        end
        return { r = r, g = g, b = b }
    end

    local function solidBackdrop()
        if DC_Widgets and DC_Widgets.SOLID_BACKDROP then
            return DC_Widgets.SOLID_BACKDROP
        end
        return {
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            tile = false, edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        }
    end

    -- the dark bar the tiles sit in; `leadLabel` is the main window's
    -- "PLAYER" device -- it names the label column of the grid below.
    local function CreateHeaderBar(parent, name, px, py, w, leadLabel)
        local bar = CreateFrame("Frame", name, parent)
        bar:SetWidth(w)
        bar:SetHeight(O.HEAD_ROW_H)
        bar:SetPoint("TOPLEFT", parent, "TOPLEFT", px, py)
        bar:SetBackdrop(solidBackdrop())
        local bg = colorOf("theadBg", 0.078, 0.094, 0.129)
        local br = colorOf("goldSoft", 0.329, 0.275, 0.165)
        bar:SetBackdropColor(bg.r, bg.g, bg.b, 1)
        bar:SetBackdropBorderColor(br.r, br.g, br.b, 1)
        local lead = bar:CreateFontString(name .. "Lead", "OVERLAY")
        ApplyFontFixed(lead, 8)
        local fc = colorOf("faint", 0.357, 0.384, 0.439)
        lead:SetTextColor(fc.r, fc.g, fc.b)
        lead:SetPoint("LEFT", bar, "LEFT", 6, 0)
        lead:SetText(leadLabel or "")
        return bar
    end

    -- one column tile. `left` is the x of the column INSIDE the bar (the
    -- tile spans the full O.CELL_W, the check button below is centered in
    -- it by O.CELL_INSET), so tile and cells cannot drift apart.
    local function CreateHeaderTile(bar, name, left)
        local t = CreateFrame("Frame", name, bar)
        t:SetWidth(O.CELL_W)
        t:SetHeight(O.HEAD_H)
        t:SetPoint("LEFT", bar, "LEFT", left, 0)
        t:SetBackdrop(solidBackdrop())
        local bg = colorOf("panel2", 0.09, 0.106, 0.137)
        local br = colorOf("line", 0.149, 0.173, 0.216)
        t:SetBackdropColor(bg.r, bg.g, bg.b, 1)
        t:SetBackdropBorderColor(br.r, br.g, br.b, 1)
        t:EnableMouse(true)
        local lbl = t:CreateFontString(name .. "Label", "OVERLAY")
        ApplyFontFixed(lbl, 9)
        local pc = colorOf("parch", 0.839, 0.804, 0.722)
        lbl:SetTextColor(pc.r, pc.g, pc.b)
        lbl:SetPoint("TOP", t, "TOP", 0, -3)
        local sub = t:CreateFontString(name .. "Sub", "OVERLAY")
        ApplyFontFixed(sub, 7)
        local fc = colorOf("faint", 0.357, 0.384, 0.439)
        sub:SetTextColor(fc.r, fc.g, fc.b)
        sub:SetPoint("BOTTOM", t, "BOTTOM", 0, 3)
        t.dcLabelFS = lbl
        t.dcSubFS = sub
        return t
    end

    -- ----------------------------------------------------------------
    -- grid cell handlers (defined once, shared by every pooled cell)
    -- ----------------------------------------------------------------
    local function GridCellOnClick()
        local slotId = this.dcSlot
        if not slotId or not this.dcRole then
            return
        end
        local exp = expectations()
        if this.dcKind == "role" then
            -- header-row cell: bulk-toggle the slot for ALL performing
            -- classes of the role -- majority flip
            local role = this.dcRole
            local classes = O.RoleClasses(role)
            local n = table.getn(classes)
            local checked = 0
            for i = 1, n do
                if effLine(exp, role, classes[i])[slotId] then
                    checked = checked + 1
                end
            end
            local newVal = O.BulkNewValue(checked, n)
            for i = 1, n do
                local line = materializeLine(exp, role, classes[i])
                if newVal then
                    line[slotId] = true
                else
                    -- explicit false, NOT nil: a bare nil would let a
                    -- later EnsureExpectations top-up (core/config.lua)
                    -- silently re-check a slot the user deliberately
                    -- unchecked (A1 migration fix)
                    line[slotId] = false
                end
            end
        else
            local line = materializeLine(exp, this.dcRole, this.dcClass)
            if this:GetChecked() == 1 then
                line[slotId] = true
            else
                line[slotId] = false     -- explicit deselect, see above
            end
        end
        RefreshGrid()    -- resync class rows + header aggregate cells
        refreshMatrix()  -- applies immediately
    end

    local function GridCellOnEnter()
        GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
        if this.dcKind == "role" then
            GameTooltip:SetText((DC.ROLE_LABEL[this.dcRole] or this.dcRole
                or "?") .. " (all classes) - " .. (this.dcLabel or "?"),
                1, 1, 1)
            GameTooltip:AddLine("Click toggles this slot for every class of"
                .. " the role (majority flip).", 0.8, 0.8, 0.8, 1)
        else
            GameTooltip:SetText((DC.ROLE_LABEL[this.dcRole] or this.dcRole
                or "?") .. " " .. O.ClassTitle(this.dcClass) .. " - "
                .. (this.dcLabel or "?"), 1, 1, 1)
            GameTooltip:AddLine("Click to toggle whether this class is"
                .. " expected to have it in this role.", 0.8, 0.8, 0.8, 1)
        end
        GameTooltip:Show()
    end

    local function GridCellOnLeave()
        GameTooltip:Hide()
    end

    -- ----------------------------------------------------------------
    -- weapon-skill-book cell handlers (shared by every pooled cell)
    -- ----------------------------------------------------------------
    local function BookCellOnClick()
        if not this.dcName or not this.dcSkill then
            return
        end
        O.SkillBookSet(db(), this.dcName, this.dcSkill,
            this:GetChecked() == 1)
        -- No rescan needed: the assumed weapon skill is resolved when the
        -- matrix builds its view-model (it reads db.skillBooks), so the
        -- new cap is on screen with this refresh.
        refreshMatrix()
    end

    local function BookCellOnEnter()
        GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
        GameTooltip:SetText((this.dcName or "?") .. " - "
            .. (this.dcSkill or "?"), 1, 1, 1)
        -- the column header is 3 characters; without this line a dimmed
        -- cell and a bright one would be indistinguishable on hover
        if this.dcWorn then
            GameTooltip:AddLine("Currently equipped on this skill line.",
                0.6, 0.8, 0.6, 1)
        else
            GameTooltip:AddLine("Trainable by this class, nothing equipped"
                .. " on it right now.", 0.7, 0.7, 0.7, 1)
        end
        GameTooltip:AddLine("Weapon skill book: +3 skill, about 0.6% hit."
            .. " Nothing on this client can read it off another player, so"
            .. " tick it by hand.", 0.8, 0.8, 0.8, 1)
        GameTooltip:Show()
    end

    -- ----------------------------------------------------------------
    -- grid pools (lazy factories; positions are index-fixed at creation
    -- because O.GridRows is identical across the three expectation tabs)
    -- ----------------------------------------------------------------
    -- alternating-row wash, one pooled texture per row of a grid. Same
    -- device and same strength as the matrix rows (DC.ZEBRA_ALPHA) -- the
    -- point is that a row can be followed across 14-15 columns by eye.
    -- Textures live on the SCROLL CHILD at BACKGROUND layer; the check
    -- buttons are child FRAMES and therefore always draw above them.
    local function EnsureZebra(store, parent, r, w, rh)
        local t = store[r]
        if t then
            return t
        end
        t = parent:CreateTexture(nil, "BACKGROUND")
        local zc = (DC.COLORS and DC.COLORS.zebra) or { r = 1, g = 1, b = 1 }
        t:SetTexture(zc.r, zc.g, zc.b)
        t:SetAlpha(DC.ZEBRA_ALPHA or 0.035)
        t:SetWidth(w)
        t:SetHeight(rh)
        t:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -((r - 1) * rh))
        store[r] = t
        return t
    end

    -- `on` is the CALLER's decision, not a parity of r: the expectation
    -- grid stripes every second CLASS row while the role headers between
    -- them stay unstriped (they carry their own emphasis), so the two
    -- indices are not the same number.
    local function PaintZebra(store, parent, r, w, rh, on)
        local t = EnsureZebra(store, parent, r, w, rh)
        -- width is re-applied every time, not just at creation: the grid
        -- narrows with the tab (ApplyEditorWidth) and a stripe left at the
        -- widest tab's width would run out under the scroll frame's edge
        t:SetWidth(w)
        if on then
            t:Show()
        else
            t:Hide()
        end
    end

    local function EnsureRowLabel(r)
        local name = "DopingControlOptRowLabel" .. r
        local fs = getglobal(name)
        if fs then
            return fs
        end
        fs = gridChild:CreateFontString(name, "ARTWORK",
            "GameFontNormalSmall")
        fs:SetJustifyH("LEFT")
        if r > gridRowMax then
            gridRowMax = r
        end
        if gridColMax[r] == nil then
            gridColMax[r] = 0
        end
        return fs
    end

    local function EnsureGridCell(r, c)
        local name = "DopingControlOptExpR" .. r .. "C" .. c
        local cb = getglobal(name)
        if cb then
            return cb
        end
        cb = CreateFrame("CheckButton", name, gridChild,
            "UICheckButtonTemplate")
        cb:SetWidth(20)
        cb:SetHeight(20)
        cb:SetPoint("TOPLEFT", gridChild, "TOPLEFT",
            O.GRID_LABEL_W + (c - 1) * O.COL_PITCH + O.CELL_INSET, -((r - 1) * O.ROW_H) - 1)
        getglobal(name .. "Text"):SetText("")
        cb:SetScript("OnClick", GridCellOnClick)
        cb:SetScript("OnEnter", GridCellOnEnter)
        cb:SetScript("OnLeave", GridCellOnLeave)
        -- Deliberately NOT pfUI-skinned (unlike the top checkboxes):
        -- SkinCheckbox puts its dark box on an own frame, and inside the
        -- scroll child its frame level clamps ABOVE the check button --
        -- the check mark rendered BEHIND the box (observed
        -- in-game). The stock template draws its check on top by
        -- construction; a slight style mix beats invisible state.
        if r > gridRowMax then
            gridRowMax = r
        end
        if gridColMax[r] == nil or c > gridColMax[r] then
            gridColMax[r] = c
        end
        return cb
    end

    -- ----------------------------------------------------------------
    -- weapon-skill-book pools (lazy; ROW and COLUMN positions are
    -- index-fixed like the expectation grid -- only which cells are SHOWN
    -- changes with the roster, see RefreshBooks)
    -- ----------------------------------------------------------------
    local function EnsureBookLabel(r)
        local name = "DopingControlOptBookName" .. r
        local fs = getglobal(name)
        if fs then
            return fs
        end
        fs = booksChild:CreateFontString(name, "ARTWORK",
            "GameFontNormalSmall")
        ApplyFont(fs, 10)
        fs:SetJustifyH("LEFT")
        fs:SetWidth(O.BOOK_NAME_W - 4)
        fs:SetPoint("TOPLEFT", booksChild, "TOPLEFT", 0,
            -((r - 1) * O.ROW_H) - 5)
        if r > booksRowMax then
            booksRowMax = r
        end
        if booksColMax[r] == nil then
            booksColMax[r] = 0
        end
        return fs
    end

    -- one pooled hint line per row, shown instead of the checkboxes for a
    -- player whose class is unknown (O.SKILLBOOK_UNREADABLE_HINT)
    local function EnsureBookRowHint(r)
        local name = "DopingControlOptBookRowHint" .. r
        local fs = getglobal(name)
        if fs then
            return fs
        end
        fs = booksChild:CreateFontString(name, "ARTWORK",
            "GameFontNormalSmall")
        ApplyFont(fs, 9)
        fs:SetJustifyH("LEFT")
        fs:SetTextColor(0.36, 0.38, 0.44)
        fs:SetPoint("TOPLEFT", booksChild, "TOPLEFT", O.BOOK_NAME_W,
            -((r - 1) * O.ROW_H) - 5)
        return fs
    end

    local function EnsureBookCell(r, c)
        local name = "DopingControlOptBookR" .. r .. "C" .. c
        local cb = getglobal(name)
        if cb then
            return cb
        end
        cb = CreateFrame("CheckButton", name, booksChild,
            "UICheckButtonTemplate")
        cb:SetWidth(20)
        cb:SetHeight(20)
        cb:SetPoint("TOPLEFT", booksChild, "TOPLEFT",
            O.BOOK_NAME_W + (c - 1) * O.COL_PITCH + O.CELL_INSET,
            -((r - 1) * O.ROW_H) - 1)
        -- no per-cell label any more: the column header carries the skill
        -- name (that IS the readability fix -- see O.BOOK_COLUMNS)
        getglobal(name .. "Text"):SetText("")
        cb:SetScript("OnClick", BookCellOnClick)
        cb:SetScript("OnEnter", BookCellOnEnter)
        cb:SetScript("OnLeave", GridCellOnLeave)
        -- deliberately NOT pfUI-skinned, for the same reason as the
        -- expectation cells above (the skin's box clamps above the check
        -- mark inside a scroll child)
        if r > booksRowMax then
            booksRowMax = r
        end
        if booksColMax[r] == nil or c > booksColMax[r] then
            booksColMax[r] = c
        end
        return cb
    end

    -- ----------------------------------------------------------------
    -- weapon-skill-book refresh: one ROW per scanned player from the
    -- CURRENT store (O.SkillBookRows), one cell per FIXED column
    -- (O.BOOK_COLUMNS), check states from db.skillBooks. A cell exists
    -- only where the player could own a book (O.BookCellState); worn
    -- skills -- the common tick -- render at full strength, the rest of
    -- the class list dimmed. A row whose class is unknown shows a hint
    -- instead of cells.
    -- ----------------------------------------------------------------
    RefreshBooks = function()
        if not booksChild then
            return
        end
        local rows = O.SkillBookRows(DC.store)
        local nRows = table.getn(rows)
        local d = db()
        for r = 1, nRows do
            local rd = rows[r]
            -- every row here IS a data row (one player), so plain parity
            PaintZebra(booksZebra, booksChild, r, O.BooksContentW(), O.ROW_H,
                math.mod(r, 2) == 0)
            local lbl = EnsureBookLabel(r)
            local cc = DC.COLORS and DC.COLORS["class" .. (rd.class or "")]
            if cc then
                lbl:SetTextColor(cc.r, cc.g, cc.b)
            else
                lbl:SetTextColor(0.79, 0.8, 0.83)
            end
            lbl:SetText(rd.name or "")
            lbl:Show()
            local rowHint = EnsureBookRowHint(r)
            if rd.unreadable then
                rowHint:SetText(O.SKILLBOOK_UNREADABLE_HINT)
                rowHint:Show()
            else
                rowHint:Hide()
            end
            for c = 1, table.getn(O.BOOK_COLUMNS) do
                local skill = O.BOOK_COLUMNS[c].skill
                local state = O.BookCellState(rd, skill)
                if state then
                    local cb = EnsureBookCell(r, c)
                    cb.dcName = rd.name
                    cb.dcSkill = skill
                    cb.dcWorn = (state == "worn")
                    -- Every offered cell renders at FULL strength: a
                    -- half-transparent CHECKED box reads as broken, not
                    -- as a hint. Which weapon is in hand is a scan
                    -- detail, not a property of the book, so it belongs
                    -- in the tooltip (BookCellOnEnter) and nowhere else. Which
                    -- weapon is in hand is a scan detail, not a property
                    -- of the book, so it belongs in the tooltip
                    -- (BookCellOnEnter) and nowhere else.
                    cb:SetAlpha(1)
                    cb:SetChecked(O.SkillBookGet(d, rd.name, skill)
                        and 1 or nil)
                    cb:Show()
                else
                    local cb = getglobal("DopingControlOptBookR" .. r
                        .. "C" .. c)
                    if cb then
                        cb:Hide()
                    end
                end
            end
        end
        -- surplus pooled rows (the roster shrinks between scans)
        for r = nRows + 1, booksRowMax do
            local lbl = getglobal("DopingControlOptBookName" .. r)
            if lbl then
                lbl:Hide()
            end
            if booksZebra[r] then
                booksZebra[r]:Hide()
            end
            local rowHint = getglobal("DopingControlOptBookRowHint" .. r)
            if rowHint then
                rowHint:Hide()
            end
            for c = 1, (booksColMax[r] or 0) do
                local cb = getglobal("DopingControlOptBookR" .. r
                    .. "C" .. c)
                if cb then
                    cb:Hide()
                end
            end
        end
        local contentH = nRows * O.ROW_H + 4
        if contentH < 1 then
            contentH = 1
        end
        booksChild:SetHeight(contentH)
        if booksHintFS then
            booksHintFS:SetText(O.SkillBookHint(rows) or "")
        end
    end

    -- ----------------------------------------------------------------
    -- expectation grid refresh (headers, row labels, check states, tabs)
    -- ----------------------------------------------------------------
    RefreshGrid = function()
        local slots = slotsForTab(expTab)
        local nSlots = table.getn(slots)
        local exp = expectations()
        local rows = O.GridRows(expTab)
        local nRows = table.getn(rows)
        -- column header tiles (outside the scroll frame, sticky). The tile
        -- SET is per tab (consumables 11, class buffs 6, equipment 14), so
        -- unlike the book grid's fixed columns these are refilled here.
        for c = 1, O.MAX_SLOT_COLS do
            local head = getglobal("DopingControlOptColHead" .. c)
            if head then
                if c <= nSlots then
                    local sd = slots[c]
                    head.dcLabelFS:SetText(sd.label or sd.id)
                    head.dcSubFS:SetText(sd.sub or "")
                    head.dcTooltip = sd.full or sd.id
                    head:Show()
                else
                    head:Hide()
                end
            end
        end
        -- grouped rows: role header + indented class rows.
        -- Stripes span THIS tab's content width, not the widest tab's.
        local gridW = O.GridContentW(nSlots)
        local dataIdx = 0
        for r = 1, nRows do
            local rd = rows[r]
            local lbl = EnsureRowLabel(r)
            local indent = 0
            if rd.kind == "class" then
                indent = 12
            end
            -- stripe every second CLASS row; role headers stay clean
            if rd.kind == "class" then
                dataIdx = dataIdx + 1
                PaintZebra(gridZebra, gridChild, r, gridW, O.ROW_H,
                    math.mod(dataIdx, 2) == 0)
            else
                PaintZebra(gridZebra, gridChild, r, gridW, O.ROW_H, false)
            end
            if lbl.dcIndent ~= indent then
                lbl.dcIndent = indent
                lbl:ClearAllPoints()
                lbl:SetPoint("TOPLEFT", gridChild, "TOPLEFT", indent,
                    -((r - 1) * O.ROW_H) - 5)
                lbl:SetWidth(O.GRID_LABEL_W - indent)
            end
            if rd.kind == "role" then
                -- 1.12 has no bold flag: size 11 + role color carry the
                -- header hierarchy over the size-10 class rows
                ApplyFont(lbl, 11)
                local rc = DC.COLORS and DC.COLORS["role" .. rd.role]
                if rc then
                    lbl:SetTextColor(rc.r, rc.g, rc.b)
                end
                lbl:SetText(DC.ROLE_LABEL[rd.role] or rd.role)
            else
                ApplyFont(lbl, 10)
                local cc = DC.COLORS and DC.COLORS["class" .. rd.class]
                if cc then
                    lbl:SetTextColor(cc.r, cc.g, cc.b)
                end
                lbl:SetText(O.ClassTitle(rd.class))
            end
            lbl:Show()
            for c = 1, O.MAX_SLOT_COLS do
                if c <= nSlots then
                    local sd = slots[c]
                    local cb = EnsureGridCell(r, c)
                    cb.dcKind = rd.kind
                    cb.dcRole = rd.role
                    cb.dcClass = rd.class
                    cb.dcSlot = sd.id
                    cb.dcLabel = sd.full or sd.id
                    if rd.kind == "role" then
                        -- aggregate cell: all classes checked = checked,
                        -- none = unchecked, mixed = checked at 0.45 alpha
                        -- (no tri-state CheckButton on 1.12)
                        local classes = O.RoleClasses(rd.role)
                        local nCl = table.getn(classes)
                        local nOn = 0
                        for k = 1, nCl do
                            if effLine(exp, rd.role, classes[k])[sd.id] then
                                nOn = nOn + 1
                            end
                        end
                        cb:SetChecked((nCl > 0 and nOn > 0) and 1 or nil)
                        if nOn > 0 and nOn < nCl then
                            cb:SetAlpha(0.45)
                        else
                            cb:SetAlpha(1)
                        end
                    else
                        cb:SetChecked(
                            effLine(exp, rd.role, rd.class)[sd.id] and 1
                            or nil)
                        cb:SetAlpha(1)
                    end
                    cb:Show()
                else
                    local cb = getglobal("DopingControlOptExpR" .. r
                        .. "C" .. c)
                    if cb then
                        cb:Hide()
                    end
                end
            end
        end
        -- hide surplus pooled rows (row count is constant today; the pool
        -- pattern keeps this safe if GridRows ever shrinks)
        for r = nRows + 1, gridRowMax do
            local lbl = getglobal("DopingControlOptRowLabel" .. r)
            if lbl then
                lbl:Hide()
            end
            if gridZebra[r] then
                gridZebra[r]:Hide()
            end
            for c = 1, (gridColMax[r] or 0) do
                local cb = getglobal("DopingControlOptExpR" .. r .. "C" .. c)
                if cb then
                    cb:Hide()
                end
            end
        end
        -- equipment-only dim note: the grid only configures ENCHANTS
        -- (empty slots are always a gap, regardless of these checkboxes)
        if eqHintFS then
            if expTab == "EQUIPMENT" then
                eqHintFS:SetText(
                    "checkboxes = enchant expectations; empty slots always count")
            else
                eqHintFS:SetText("")
            end
        end
    end

    -- ----------------------------------------------------------------
    -- editor tab switching: exactly one of {grid block, book block} is
    -- shown at a time, in the SAME position/width (see BuildOptionsFrame,
    -- which sizes the window to the taller of the two so this never
    -- resizes it). Named widgets are looked up by their global name --
    -- every one of them is created once in BuildOptionsFrame and just
    -- toggled here.
    -- ----------------------------------------------------------------
    local function ApplyEditorTabVisibility()
        local isBooks = O.IsSkillBookTab(expTab)
        local gridScroll = getglobal("DopingControlOptExpScroll")
        local hintFS = getglobal("DopingControlOptExpHint")
        local resetBtn = getglobal("DopingControlOptResetBtn")
        local bookNote = getglobal("DopingControlOptBookNote")
        local bookScroll = getglobal("DopingControlOptBookScroll")
        local gridHeadBar = getglobal("DopingControlOptHeadBar")
        local bookHeadBar = getglobal("DopingControlOptBookHeadBar")
        if isBooks then
            -- hiding the BAR hides its tiles with it (they are its
            -- children), so a stale grid tab's headers cannot bleed
            -- into this tab
            if gridHeadBar then gridHeadBar:Hide() end
            if bookHeadBar then bookHeadBar:Show() end
            if gridScroll then gridScroll:Hide() end
            if hintFS then hintFS:Hide() end
            if eqHintFS then eqHintFS:Hide() end
            if resetBtn then resetBtn:Hide() end
            if bookNote then bookNote:Show() end
            if bookScroll then bookScroll:Show() end
            if booksHintFS then booksHintFS:Show() end
        else
            -- WHICH tiles of the grid bar are lit is governed by
            -- RefreshGrid (per-tab slot count), not repeated here
            if gridHeadBar then gridHeadBar:Show() end
            if bookHeadBar then bookHeadBar:Hide() end
            if gridScroll then gridScroll:Show() end
            if hintFS then hintFS:Show() end
            if eqHintFS then eqHintFS:Show() end
            if resetBtn then resetBtn:Show() end
            if bookNote then bookNote:Hide() end
            if bookScroll then bookScroll:Hide() end
            if booksHintFS then booksHintFS:Hide() end
        end
    end

    -- ----------------------------------------------------------------
    -- Width follows the selected tab, like the main window follows its
    -- column count. Only WIDTHS change here -- every widget is anchored
    -- TOPLEFT (or to the frame's own right/bottom edge), so nothing needs
    -- repositioning when the frame narrows.
    -- ----------------------------------------------------------------
    local function EditorContentW(tab)
        if O.IsSkillBookTab(tab) then
            return O.BooksContentW()
        end
        return O.GridContentW(table.getn(slotsForTab(tab)))
    end

    local function ApplyEditorWidth()
        if not frame then
            return
        end
        local contentW = EditorContentW(expTab)
        frame:SetWidth(O.FrameWidthFor(contentW))
        if dividerTex then
            dividerTex:SetWidth(O.FrameWidthFor(contentW) - 48)
        end
        if O.IsSkillBookTab(expTab) then
            return   -- the book block is fixed-width (15 fixed columns)
        end
        local bar = getglobal("DopingControlOptHeadBar")
        if bar then
            bar:SetWidth(contentW)
        end
        local scroll = getglobal("DopingControlOptExpScroll")
        if scroll then
            scroll:SetWidth(contentW)
        end
        if gridChild then
            gridChild:SetWidth(contentW)
        end
    end

    -- the active editor tab's button is disabled (disabled = selected),
    -- every other one enabled -- walks the FULL O.EditorTabs() order (4
    -- buttons), unlike the old grid-only loop this replaces
    local function UpdateTabButtons()
        for i = 1, table.getn(EDITOR_TABS) do
            local b = getglobal("DopingControlOptTabBtn" .. i)
            if b then
                if EDITOR_TABS[i] == expTab then
                    b:Disable()
                else
                    b:Enable()
                end
            end
        end
    end

    -- single entry point for "the editor tab (may have) changed": always
    -- refreshes both pools (pooled widgets are cheap to touch even while
    -- hidden, and this keeps state consistent if the user flips tabs
    -- quickly), then shows/hides the two content blocks and the tab row.
    local function RefreshEditor()
        RefreshGrid()
        RefreshBooks()
        ApplyEditorTabVisibility()
        ApplyEditorWidth()
        UpdateTabButtons()
    end

    -- reset button: double-click-to-confirm (see header)
    local function ResetDisarmWatcher()
        if not this.dcArmedUntil or GetTime() > this.dcArmedUntil then
            this.dcArmedUntil = nil
            this:SetText("Reset expectations")
            this:SetScript("OnUpdate", nil)
        end
    end

    local function ResetOnClick()
        local now = GetTime()
        if this.dcArmedUntil and now <= this.dcArmedUntil then
            -- second click inside the window: reseed every (role, class)
            -- line from DC.DEFAULT_EXPECT (config.ResetExpectations,
            -- per-class shape; fallback: drop + re-materialize)
            this.dcArmedUntil = nil
            this:SetText("Reset expectations")
            this:SetScript("OnUpdate", nil)
            if DC.ResetExpectations then
                DC.ResetExpectations(db())
            else
                db().expectations = nil
            end
            RefreshGrid()    -- expectations() re-materializes when cleared
            refreshMatrix()
        else
            -- first click (or lapsed): arm
            this.dcArmedUntil = now + O.RESET_ARM_WINDOW
            this:SetText("Click again to confirm")
            this:SetScript("OnUpdate", ResetDisarmWatcher)
        end
    end

    local function ResetDisarm(btn)
        btn.dcArmedUntil = nil
        btn:SetText("Reset expectations")
        btn:SetScript("OnUpdate", nil)
    end

    -- ----------------------------------------------------------------
    -- frame build (lazy, once)
    -- ----------------------------------------------------------------
    local function BuildOptionsFrame()
        if frame then
            return frame
        end

        local f = CreateFrame("Frame", "DopingControlOptionsFrame", UIParent)
        f:SetWidth(widestFrameW())
        f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
        f:SetFrameStrata("DIALOG")
        f:SetBackdrop(panelBackdrop())
        f:SetBackdropColor(1, 1, 1, 0.97)
        f:SetMovable(true)
        f:EnableMouse(true)
        f:RegisterForDrag("LeftButton")
        f:SetScript("OnDragStart", function() this:StartMoving() end)
        f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
        f:SetClampedToScreen(true)

        -- title (gold)
        local title = f:CreateFontString("DopingControlOptionsTitle",
            "ARTWORK", "GameFontNormal")
        title:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -16)
        ApplyFont(title, 14)
        local g = DC.COLORS and DC.COLORS.goldHi
        if g then
            title:SetTextColor(g.r, g.g, g.b)
        else
            title:SetTextColor(0.85, 0.66, 0.31)
        end
        title:SetText("DopingControl")

        -- close X
        local closeX = CreateFrame("Button", "DopingControlOptionsCloseX",
            f, "UIPanelCloseButton")
        closeX:SetPoint("TOPRIGHT", f, "TOPRIGHT", -8, -8)
        if pfUI and pfUI.api and pfUI.api.SkinCloseButton then
            pfUI.api.SkinCloseButton(closeX)
        end

        -- layout cursor
        local x, y = 24, -44
        local function place(w, dy)
            w:SetPoint("TOPLEFT", f, "TOPLEFT", x, y)
            y = y + dy
        end

        -- ------------------------------------------------------------
        -- checkboxes
        -- ------------------------------------------------------------
        local cbTest = CreateCheckbox(f, "Test mode (simulated raid)",
            function() return db().testMode end,
            function(v)
                if DC.SetTestMode then
                    DC.SetTestMode(v)     -- core/init.lua: swaps store + rescan
                else
                    db().testMode = v     -- fallback if init not loaded yet
                end
            end)
        place(cbTest, -28)
        AddTooltip(cbTest, "Replace the scan with a fixed simulated raid.\nChat outputs are disabled while active.")

        -- Demo mode sits right below the test-mode row: same idea (the
        -- simulated raid), plus a transient expectation layer that shows
        -- all six school protection columns at once. A BUTTON, not a
        -- checkbox -- the state is never saved, so it relabels itself
        -- instead of pretending to be a stored setting.
        local btnDemo = CreateButton(f, "DopingControlOptDemoBtn",
            O.DemoButtonLabel(), 200,
            function()
                -- Capture the widget BEFORE SetDemoMode runs. `this` is a
                -- plain global the 1.12 dispatcher sets per script call and
                -- never restores: SetDemoMode synchronously drives
                -- DC_Matrix.Refresh -> M.Render, whose leftover-hide loop
                -- Hide()s pooled rows and group heads that carry their own
                -- OnHide handlers (ui/matrix.lua M.RowOnHide /
                -- M.GroupHeadOnHide -- they read `this` themselves). After
                -- that nested dispatch `this` is the last-hidden widget, and
                -- a row frame is a plain Frame with no SetText. Same order
                -- the other handlers in this file already keep
                -- (GridCellOnClick, ResetOnClick): finish the `this` reads
                -- and writes before refreshing anything.
                local btn = this
                if DC.SetDemoMode then
                    DC.SetDemoMode(not DC.demoMode)
                end
                btn:SetText(O.DemoButtonLabel())
            end)
        place(btnDemo, -28)
        AddTooltip(btnDemo, "Simulated raid with ALL SIX protection columns"
            .. " (fire, frost, nature, shadow, holy, arcane) shown at once."
            .. "\nYour saved expectations are left untouched, and demo mode"
            .. " is never saved - it ends with the next reload."
            -- the demo layer is snapshotted on entry (core/demo.lua), so
            -- grid edits made while it is armed do not reach the matrix
            .. "\nThe view is a snapshot: restart demo mode to pick up"
            .. " expectation changes made while it is running.")

        local cbMinimap = CreateCheckbox(f, "Show minimap button",
            function() return db().showMinimapButton end,
            function(v)
                db().showMinimapButton = v
                if DC_Minimap and DC_Minimap.Update then
                    DC_Minimap.Update()
                end
            end)
        place(cbMinimap, -28)

        local cbScan = CreateCheckbox(f, "Auto-scan on ready check",
            function() return db().readyCheckScan end,
            function(v) db().readyCheckScan = v end)
        place(cbScan, -28)

        local cbOpen = CreateCheckbox(f, "Auto-open on ready check",
            function() return db().readyCheckOpen end,
            function(v) db().readyCheckOpen = v end)
        place(cbOpen, -28)

        -- ------------------------------------------------------------
        -- divider
        -- ------------------------------------------------------------
        local divider = f:CreateTexture("DopingControlOptDivider", "ARTWORK")
        divider:SetHeight(1)
        divider:SetWidth(widestFrameW() - 48)
        local lc = DC.COLORS and DC.COLORS.line
        if lc then
            divider:SetTexture(lc.r, lc.g, lc.b)
        else
            divider:SetTexture(0.15, 0.17, 0.22)
        end
        divider:SetPoint("TOPLEFT", f, "TOPLEFT", x, y - 6)
        dividerTex = divider
        y = y - 14

        -- ------------------------------------------------------------
        -- expectation editor (role x class grouped rows)
        -- ------------------------------------------------------------
        local heading = f:CreateFontString("DopingControlOptExpHeading",
            "ARTWORK", "GameFontNormalSmall")
        ApplyFont(heading, 11)
        heading:SetTextColor(0.79, 0.8, 0.83)
        heading:SetText("Expectations (role x class x slot)")
        place(heading, -22)

        -- tab selector: one small button per EDITOR_TABS entry (the three
        -- expectation tabs plus Skill books last); the selected one is
        -- disabled. widestFrameW() already accounts for the widest of the two
        -- content blocks below, so this fits comfortably.
        local tabRowY = y
        for i = 1, table.getn(EDITOR_TABS) do
            local tabKey = EDITOR_TABS[i]
            local b = CreateButton(f, "DopingControlOptTabBtn" .. i,
                O.EditorTabLabel(tabKey), 108,
                function()
                    expTab = this.dcTab
                    RefreshEditor()
                end)
            b:SetHeight(20)
            b.dcTab = tabKey
            b:SetPoint("TOPLEFT", f, "TOPLEFT", x + (i - 1) * 114, tabRowY)
        end
        y = y - 26

        -- shared top for the two mutually exclusive content blocks below
        -- (only one is ever shown, ApplyEditorTabVisibility); the window
        -- height is fixed to whichever block is taller so switching tabs
        -- never resizes it (see the height computation after both).
        local contentTop = y

        -- ------------------------------------------------------------
        -- BLOCK 1: expectation grid (Consumables / Class buffs /
        -- Equipment) -- column headers, scrolling grouped grid, hint,
        -- equipment-only note, reset button
        -- ------------------------------------------------------------
        local gy = contentTop

        -- column header bar + tiles (outside the scroll frame, so they stay
        -- put while the grid scrolls) -- same furniture as the main window
        local gridHeadBar = CreateHeaderBar(f, "DopingControlOptHeadBar",
            x, gy, gridWidest(), "ROLE / CLASS")
        for c = 1, O.MAX_SLOT_COLS do
            local head = CreateHeaderTile(gridHeadBar,
                "DopingControlOptColHead" .. c, O.GRID_LABEL_W + (c - 1) * O.COL_PITCH)
            AddTooltip(head, "")
        end
        gy = gy - O.HEAD_ROW_H - 2

        -- scroll frame around the grouped grid (the 25 rows would be
        -- ~550px -- cap at O.GRID_MAX_H, scroll the rest)
        local rows = O.GridRows(expTab)
        local nRows = table.getn(rows)
        local contentH = nRows * O.ROW_H + 4
        local gridH = contentH
        if gridH > O.GRID_MAX_H then
            gridH = O.GRID_MAX_H
        end
        local scroll = CreateFrame("ScrollFrame", "DopingControlOptExpScroll",
            f, "UIPanelScrollFrameTemplate")
        scroll:SetPoint("TOPLEFT", f, "TOPLEFT", x, gy)
        scroll:SetWidth(gridWidest())
        scroll:SetHeight(gridH)
        gridChild = CreateFrame("Frame", "DopingControlOptExpScrollChild",
            scroll)
        gridChild:SetWidth(gridWidest())
        gridChild:SetHeight(contentH)
        scroll:SetScrollChild(gridChild)
        if pfUI and pfUI.api and pfUI.api.SkinScrollbar then
            pfUI.api.SkinScrollbar(
                getglobal("DopingControlOptExpScrollScrollBar"))
        end
        gy = gy - gridH - 8

        -- hint
        local hint = f:CreateFontString("DopingControlOptExpHint",
            "ARTWORK", "GameFontNormalSmall")
        ApplyFont(hint, 9)
        hint:SetTextColor(0.36, 0.38, 0.44)
        hint:SetText("Click a cell to toggle - applies immediately."
            .. " Role-row cells set all classes of the role.")
        hint:SetPoint("TOPLEFT", f, "TOPLEFT", x, gy)
        gy = gy - 12

        -- equipment-only note (RefreshGrid sets the text; empty string on
        -- every other tab, so it costs one small blank line there)
        local eqHint = f:CreateFontString("DopingControlOptEqHint",
            "ARTWORK", "GameFontNormalSmall")
        ApplyFont(eqHint, 9)
        eqHint:SetTextColor(0.36, 0.38, 0.44)
        eqHint:SetText("")
        eqHint:SetPoint("TOPLEFT", f, "TOPLEFT", x, gy)
        gy = gy - 14
        eqHintFS = eqHint

        -- reset button (double-click-to-confirm)
        local resetBtn = CreateButton(f, "DopingControlOptResetBtn",
            "Reset expectations", 160, ResetOnClick)
        resetBtn:SetPoint("TOPLEFT", f, "TOPLEFT", x, gy)
        gy = gy - 30
        AddTooltip(resetBtn,
            "Restore the default expectations for every class.\nFirst click arms, second click within "
            .. O.RESET_ARM_WINDOW .. " s confirms.")

        local gridBlockBottom = gy

        -- ------------------------------------------------------------
        -- BLOCK 2: weapon skill books -- explanation line at the TOP
        -- (same spot the column headers occupy in block 1), then the
        -- scrolling, wrapped skill list, then the empty-state hint
        -- ------------------------------------------------------------
        local by = contentTop

        local bookNote = f:CreateFontString("DopingControlOptBookNote",
            "ARTWORK", "GameFontNormalSmall")
        ApplyFont(bookNote, 9)
        bookNote:SetTextColor(0.36, 0.38, 0.44)
        bookNote:SetText("A book is worth about 0.6% hit and cannot be read"
            .. " off another player - tick what you know.")
        bookNote:SetPoint("TOPLEFT", f, "TOPLEFT", x, by)
        by = by - 16

        -- sticky column header bar (outside the scroll frame, so it stays
        -- put while the roster scrolls) -- same furniture as the
        -- expectation grid above and as the main window, only with a FIXED
        -- set of labels (O.BOOK_COLUMNS), filled once here.
        local bookHeadBar = CreateHeaderBar(f, "DopingControlOptBookHeadBar",
            x, by, O.BooksContentW(), "PLAYER")
        for c = 1, table.getn(O.BOOK_COLUMNS) do
            local cd = O.BOOK_COLUMNS[c]
            local head = CreateHeaderTile(bookHeadBar,
                "DopingControlOptBookColHead" .. c,
                O.BOOK_NAME_W + (c - 1) * O.COL_PITCH)
            head.dcLabelFS:SetText(cd.label)
            head.dcSubFS:SetText(cd.sub or "")
            AddTooltip(head, cd.full or cd.skill)
        end
        by = by - O.HEAD_ROW_H - 2

        -- fixed height: the roster length must never resize the window;
        -- width fits the fixed weapon-skill column set (O.BooksContentW)
        -- Height: stretch this block so it ends exactly where the (taller)
        -- grid block ends -- the window is sized to the taller of the two
        -- and a fixed cap would leave dead space under this tab. 24 = the
        -- 8 px gap plus the 16 px hint line that still follow below.
        -- Floored at O.BOOKS_MAX_H so this block, not a shrinking grid
        -- block, decides the minimum.
        local booksScrollH = by - gridBlockBottom - 24
        if booksScrollH < O.BOOKS_MAX_H then
            booksScrollH = O.BOOKS_MAX_H
        end
        local bookScroll = CreateFrame("ScrollFrame",
            "DopingControlOptBookScroll", f, "UIPanelScrollFrameTemplate")
        bookScroll:SetPoint("TOPLEFT", f, "TOPLEFT", x, by)
        bookScroll:SetWidth(O.BooksContentW())
        bookScroll:SetHeight(booksScrollH)
        booksChild = CreateFrame("Frame", "DopingControlOptBookScrollChild",
            bookScroll)
        booksChild:SetWidth(O.BooksContentW())
        booksChild:SetHeight(booksScrollH)
        bookScroll:SetScrollChild(booksChild)
        if pfUI and pfUI.api and pfUI.api.SkinScrollbar then
            pfUI.api.SkinScrollbar(
                getglobal("DopingControlOptBookScrollScrollBar"))
        end
        by = by - booksScrollH - 8

        -- empty state (RefreshBooks sets the text; empty string once rows
        -- exist, so it costs one blank line then)
        local bookHint = f:CreateFontString("DopingControlOptBookHint",
            "ARTWORK", "GameFontNormalSmall")
        ApplyFont(bookHint, 9)
        bookHint:SetTextColor(0.36, 0.38, 0.44)
        bookHint:SetText("")
        bookHint:SetPoint("TOPLEFT", f, "TOPLEFT", x, by)
        by = by - 16
        booksHintFS = bookHint

        local booksBlockBottom = by

        -- the window is sized to the TALLER of the two blocks (more
        -- negative y = descended further) so it never jumps when the
        -- Skill books tab is selected
        if gridBlockBottom < booksBlockBottom then
            y = gridBlockBottom
        else
            y = booksBlockBottom
        end

        -- version footer
        local footer = f:CreateFontString("DopingControlOptFooter",
            "ARTWORK", "GameFontNormalSmall")
        ApplyFont(footer, 9)
        footer:SetTextColor(0.6, 0.6, 0.6)
        local ver = GetAddOnMetadata
            and GetAddOnMetadata("DopingControl", "Version")
        footer:SetText("DopingControl " .. (ver or ""))
        footer:SetPoint("BOTTOM", f, "BOTTOM", 0, 10)

        -- window height: computed from the layout cursor (grows
        -- with the grid but stays bounded by the scroll cap);
        -- 34 = footer strip below the last placed widget
        f:SetHeight(-y + 34)

        -- OnShow repopulates every widget from DB
        f:SetScript("OnShow", function()
            for i = 1, table.getn(checkboxes) do
                local cb = checkboxes[i]
                cb:SetChecked(cb.dcGet() and 1 or nil)
            end
            RefreshEditor()  -- grid/books pools + tab visibility + buttons
            ResetDisarm(getglobal("DopingControlOptResetBtn"))
            -- demo mode can have been toggled by /dc demo meanwhile
            local bd = getglobal("DopingControlOptDemoBtn")
            if bd then
                bd:SetText(O.DemoButtonLabel())
            end
        end)

        -- ESC closes
        table.insert(UISpecialFrames, "DopingControlOptionsFrame")

        -- widgets are built shown by default; establish the correct
        -- initial block visibility once even though OnShow will redo it
        -- on every real Show() (defensive against odd load orders, same
        -- spirit as the rest of this file)
        frame = f
        ApplyEditorTabVisibility()
        -- the per-tab WIDTH is applied by RefreshEditor on the first
        -- OnShow, not here: BuildOptionsFrame sits one upvalue under Lua
        -- 5.0's hard limit of 32, and capturing ApplyEditorWidth as well
        -- pushes it over ("too many upvalues" -- a COMPILE error, i.e. the
        -- whole file stops loading). The frame is built at its widest, so
        -- the only cost is that it stays wide until the first Show().

        f:Hide()
        return f
    end

    -- ----------------------------------------------------------------
    -- public API
    -- ----------------------------------------------------------------
    function O.Toggle()
        local f = BuildOptionsFrame()
        if f:IsShown() then
            f:Hide()
        else
            f:Show()
        end
    end

    function O.Open()
        local f = BuildOptionsFrame()
        f:Show()
    end

    -- external state changes (/dc test, /dc demo, minimap toggle) can
    -- resync an open options window
    function O.Refresh()
        if frame and frame:IsShown() then
            for i = 1, table.getn(checkboxes) do
                local cb = checkboxes[i]
                cb:SetChecked(cb.dcGet() and 1 or nil)
            end
            local bd = getglobal("DopingControlOptDemoBtn")
            if bd then
                bd:SetText(O.DemoButtonLabel())
            end
            RefreshEditor()  -- a fresh scan can add or drop book rows
        end
    end

    -- convenience alias (minimap right-click / slash handler)
    DC.ToggleOptions = O.Toggle

end
