-- DopingControl core/model.lua
-- Pure view-model layer: classify cells, build the matrix view-model,
-- format whisper/report strings. NO WoW API, NO frames -- offline-testable
-- under real Lua 5.0.
--
-- Covers: cell states, ready pill, chat formats (whisper/report separators
-- are pure ASCII: "-" and "|", no middle dots).
--
-- Hard rules implemented here:
--   * UNKNOWN is NEVER condensed into MISSING -- not in cells, not in sums,
--     not in pills, not in footer, not in the report.
--     Concretely: UNKNOWN cells count neither as ready nor as gap; they are
--     excluded from BOTH sum numbers (sumHas/sumExpected count only
--     determinate = HAS/MISSING cells).
--   * Sums only for readable players (sumHas/sumExpected nil otherwise).
--   * Footer and report count only readable players.
--   * Report lines <= M.MAX_LINE (240) chars, overflow continues on
--     "  ... continued:" lines (chat hard limit is 255).
--   * Whisper/report strings are pure ASCII.
--   * Aura identity: spell-ID before name (different buffs can collide on
--     one display name, e.g. "Shadow Protection" the buff vs. the potion
--     effect). When the scan delivered ANY aura IDs
--     for a player, a bare name match no longer counts for table entries
--     that have known IDs (the aura carrying that name would have matched
--     by ID if it really were that buff). Names stay authoritative for
--     entries without known IDs (e.g. "Well Fed") and for ID-less scans.
--
-- Deliberate resolutions (edge cases decided here, documented so they
-- stay deliberate):
--   * ench + EMPTY slot: an item property, not an expectation --
--     independent of the expectation checkbox. Slots that HAVE an
--     exemption table (OFFH/RNGD) treat an EMPTY slot as NOTEXP "no item"
--     (nothing to enchant; 2H users have no off hand, casters may have no
--     ranged weapon). EVERY OTHER empty ench slot -- armor AND the
--     enchantable neck/ring slots -- is ALWAYS a gap: MISSING with detail
--     "<full> empty", regardless of whether the slot is expected. Empty
--     gear is a problem the expectation grid cannot suppress; the grid
--     only governs the ENCHANT question for a slot that has an item (see
--     the ench branch below). Trinket-kind tiles (T1/T2) mirror this:
--     EMPTY is always MISSING "<full> empty"; the expectation parameter
--     is not even read for them.
--   * Gear entries may carry an optional subType field (itemSubType,
--     GetItemInfo return 6) -- required by the ENCH_EXEMPT check. Absent
--     subType = no exemption.
--   * Row "ready" = readable AND gaps==0 AND unknowns==0 ("verified
--     complete" -- an unverified (UNKNOWN) cell can never be
--     part of a green pill).
--   * Trinket HAS detail is the gear entry TABLE (quality/name/texture for
--     the tile + tooltip) and hit HAS detail is the breakdown table; all
--     other details are strings or nil.
--   * Footer name lists and the "Not readable" list are sorted
--     alphabetically (deterministic across scans).
--
-- Cell shape:
--   aura/weapon cells:  { state, detail, icon = texturePath|nil }
--     icon ONLY when state==HAS; aura kind = texture of the matched buff
--     name from player.auras.textures (optional store field -- nil-guarded,
--     absent/partial textures => icon nil); weapon kind = always nil for
--     now. For ID matches the matched name is idToName[id]; when that is
--     nil the icon stays nil.
--   ench/trinket cells: { state, detail, item = gearEntry|nil }
--     item = the gear entry TABLE BY REFERENCE (model is read-only over
--     the store, no copy) whenever the entry is a real item table -- HAS,
--     enchant missing, subtype-exempt NOTEXP, and (ench kind only) the
--     not-expected NOTEXP case (the item still rides along so the tile
--     keeps rendering -- see the ench branch below); nil for "EMPTY" and
--     unknown (gear is not consulted, or the slot never held an item).
--   resist cells: { state, detail, value = number|nil } -- RESIST tab
--     only (see below); value is the resistance total, set on HAS, nil on
--     UNKNOWN.
--   hit cells: { state, detail, value, cap, capMet } -- HIT tab only;
--     value/cap are percentages with one decimal (set on HAS, nil
--     otherwise), detail is the breakdown TABLE on HAS and a plain reason
--     string on NOTEXP/UNKNOWN.
--   classify() accordingly returns (state, detail, icon, item, value) --
--   the trailing returns are nil for callers/kinds that do not use them.
--   `value` is used ONLY by kind=="resist" (build() copies it onto
--   cell.value); every other kind returns it as nil.
--
-- DEBUFFS tab, class exemptions, out-of-range flag:
--   * DEBUFFS tab: vm.slots is built DYNAMICALLY from the store
--     -- one column per distinct debuff name across READABLE players,
--     sorted by afflicted count desc then alpha, capped at M.DEBUFF_CAP
--     (10) columns; slot entries { id=name, label=nil, full=name,
--     kind="debuff", icon=texture|nil }. Cell semantics: afflicted =>
--     MISSING (detail "Afflicted: <name>", icon = debuff texture -- the
--     ONE kind whose icon rides on a non-HAS state); clean => HAS (icon
--     nil); unreadable (aurasRead false) => UNKNOWN. Zero debuffs is a
--     LEGITIMATE clean state -- an absent/empty player.debuffs on a
--     readable player is HAS, never a read failure. Expectations are NOT
--     consulted (every column applies to every role); whisperEligible is
--     ALWAYS false on DEBUFFS; ready pill formula unchanged (readable, 0
--     gaps, 0 unknowns = "clean players"); footer/report machinery
--     unchanged (afflicted counts per debuff). More than DEBUFF_CAP
--     distinct debuffs: columns stay capped, vm.debuffMore carries the
--     overflow and reportLines appends "+N more debuff types".
--   * Class-based enchant exemption: DC.ENCH_CLASS_EXEMPT
--     [slotId][class] => NOTEXP (detail "no ranged enchant for this
--     class"), checked FIRST in the ench branch -- before gearRead/
--     UNKNOWN, before EMPTY, before the expectation -- so it wins over
--     every other equipment outcome, readable or not.
--   * Out-of-range flag: rowVM.far = true iff readable and
--     player.distance and player.distance >= DC.FAR_LIMIT;
--     vm.coverage.far = count. Diagnostic ONLY -- far changes no state,
--     no sum, no pill.
--
-- RESIST and HIT tabs (display only, no gaps):
--   * Two static-column tabs share ONE set of rules. vm.slots is
--     DC.SLOTS_RESIST (the five resistance schools) resp. DC.SLOTS_HIT
--     (the eight hit columns), always ALL of them, unconditionally --
--     unlike the equipment/consumable tabs, nothing here is gated by
--     expectations, there are none to consult.
--   * classify() kind=="resist": not player.resistsRead => UNKNOWN (same
--     unreadableReason convention as every other kind); otherwise ALWAYS
--     HAS, value = player.resists[slotdef.school] or 0 (0 is a legitimate
--     resistance, not a read failure), detail = "<full>: <value>".
--     NEVER MISSING, NEVER NOTEXP -- a display tab has no gaps.
--   * Row/group aggregates on BOTH tabs: sumHas/sumExpected stay nil
--     (mirrors DEBUFFS); whisperEligible is ALWAYS false; gaps stay 0
--     (nothing can produce MISSING here), so the ready pill is vacuously
--     "all clean" and not rendered as a readiness claim by the UI.
--   * vm.colAvg = { [slotId] = the column's average over the players who
--     DELIVER that column } -- resistance columns average over
--     player.resistsRead == true, hit columns over the players whose hit
--     cell came out HAS. Computed straight off store.players (independent
--     of row.readable, which tracks aura/online status, not resistance or
--     hit readability).
--   * vm.colCount = { [slotId] = the DENOMINATOR that column's average
--     was divided by }. Anything that PRINTS a population (the report)
--     must take it from here: the ranged hit column drops every player
--     without a ranged weapon, so its denominator differs from any
--     channel-wide reader count. Those channel counts are
--     vm.resistReadable (resistsRead) and vm.hitReadable (hit.hitRead).
--   * vm.resistAvg / vm.resistCount are kept as ALIASES of colAvg/
--     colCount (the same tables, not copies) so readers written when the
--     resistances were the only display tab keep working.
--
-- Hit columns (HIT tab, kind=="hit"):
--   * DC.SLOTS_HIT carries EIGHT columns: melee and ranged hit
--     (slotdef.hitKind "melee"/"ranged"), then one column per magic
--     school (hitKind "spell" plus slotdef.school = "arcane" / "fire" /
--     "frost" / "holy" / "nature" / "shadow"). A school column shows the
--     generic spell hit PLUS that school's own bonuses -- which is the
--     entire point of the tab: two casters of the same class can sit at
--     different numbers per school.
--   * They ride on the SAME display-only rules as the resistances:
--     expectations are never consulted, row sums stay nil, no cell can
--     become a gap, no whisper.
--   * Readability is its own flag again: player.hit.hitRead. No hit table
--     or hitRead == false => UNKNOWN. There is no hit API on this client
--     (no GetHitModifier / GetSpellHitModifier / GetCombatRating), so the
--     numbers are summed from item tooltips by the scan -- an unreadable
--     unit yields no hit at all, never a 0.
--   * Otherwise HAS with cell.value = the hit percentage (one decimal),
--     cell.cap = the cap that applies to THIS player, cell.capMet =
--     value >= cap, and cell.detail = the breakdown TABLE below (the
--     second kind after trinket HAS whose detail is a table, not a
--     string).
--   * ONE exception to "readable => HAS": the ranged column is NOTEXP for
--     a player without a physical ranged weapon (bow/gun/crossbow/thrown)
--     -- a caster holding a wand has no ranged-hit question. NOTEXP needs
--     readable data, so an unreadable player stays UNKNOWN (unlike the
--     expectation kinds, where NOTEXP wins over UNKNOWN).
--   * Melee and ranged caps shrink with weapon skill (the linear scaling
--     of the current patch); the spell cap does not. The cap arithmetic
--     itself is NOT reimplemented here -- DC_Hit.MeleeCap/RangedCap
--     (scan/hit.lua, pure) own it, DC.HIT_CAPS (data/hit.lua) owns the
--     constants, and every call is existence-guarded: without that module
--     the cells show their numbers and make no cap claim.
--   * The weapon skill behind a melee/ranged cap is resolved HERE, per
--     build: the own character's MEASURED skill (hit.skillExact) wins,
--     everyone else gets DC_Hit.SkillFor's estimate = base + racial
--     specialization (player.race) + weapon-skill book. The book flags
--     are the build() `books` param (db.skillBooks), so ticking one in
--     the options changes the cap on the next REFRESH -- the scan
--     deliberately does not bake it in, and re-deriving the estimate here
--     is idempotent (a book is never added twice). Each column resolves
--     its OWN skill line: a bow has nothing to do with the measured
--     main-hand skill.
--   * There is no separate ranged-hit item stat in 1.12: the generic
--     "+N% chance to hit" line covers melee AND ranged, so both columns
--     read the same summed gear number and differ only in cap and weapon
--     skill.
--   * THREE contributions per column, kept apart end to end so the cell
--     tooltip can name each one instead of showing one unlabeled number:
--       gear    hit.gearMelee / hit.gearSpell, summed from item tooltips
--               by scan/hit.lua. Falls back to the pre-split totals
--               hit.melee / hit.spell when the split fields are absent,
--               so a hand-built or older store still classifies right.
--       auras   hit.auraMelee / auraRanged / auraSpell, folded on top by
--               scan/engine.lua's BuildPlayer from DC.HIT_AURAS.
--               hit.aurasRead == false means the aura contribution is
--               UNKNOWN, not a verified zero (the tooltip says so).
--       talents hit.talentMelee / talentRanged / talentSpell, plus
--               hit.talentSchools per school and hit.talentOffhand (the
--               warrior off-hand talent -- deliberately NOT part of the
--               melee number, it only rides in the tooltip).
--     hit.melee / hit.spell stay the TOTAL for backward compatibility.
--   * TALENTS ARE GATED BY hit.talentsRead, read STRICTLY (== true):
--     GetTalentInfo has no foreign-unit channel, so other players' ranks
--     arrive over this server's inspect protocol (scan/talents.lua) and
--     land in the same fields via the same DC_Hit.TalentHit. Anything but
--     true means no plausible reply has arrived YET (a "not yet", not an
--     "impossible"), so the number EXCLUDES talents and every tooltip
--     says exactly that. Reading the
--     flag strictly is what keeps the number and that sentence from ever
--     contradicting each other -- an absent flag never invents a bonus.
--   * Per-school value = generic spell hit (gear + aura + talent) + that
--     school's GEAR bonus (hit.schools, the school-only item lines) +
--     that school's TALENT bonus (hit.talentSchools). School keys arrive
--     in TWO spellings -- the gear table uses the DC.HIT_SCHOOLS display
--     names ("Frost"), slots and talents the lower-case token ("frost") --
--     and are resolved case-insensitively with FIRST MATCH ONLY, never a
--     sum, so a table carrying both spellings cannot double-count.
--   * cell.detail (hit breakdown table): { kind, value, cap, capBase,
--     capMet, gear, aura, aurasRead, talent, talentsRead, talentOffhand,
--     generic, school, gearSchool, talentSchool, skill, skillBase,
--     skillExact, skillName, book, dualWield, capWhite, mainSub, offSub,
--     rangedSub, schools }. Everything the cell tooltip needs -- capBase
--     is the unadjusted cap and skillBase the unmodified weapon skill, so
--     the tooltip can show the derivation; the UI never recomputes hit
--     math. `generic` is the school-independent part of a spell column,
--     `schools` rides along on a school-LESS spell column only (on the
--     HIT tab every school has its own column, so listing the others in a
--     column's tooltip would be noise). The spell cap carries no
--     capUnverified flag any more: it is derived from the server's own
--     99%-hit clamp (data/hit.lua DC.HIT_CAPS header), not a
--     quoted-but-unmeasured number.
--   * vm.colAvg holds one entry per hit column (average over the players
--     whose hit cell is HAS, one decimal -- a NOTEXP ranged cell is left
--     out instead of counted as 0) and vm.hitReadable is the channel's
--     reader count.
--
-- Weapon temp-enchant name:
--   * weaponMainName: weapon-kind HAS detail = player.weaponMainName (the
--     temp-enchant NAME the scan delivered) when set, else the generic
--     "main hand temp enchant". States are untouched -- the name never
--     influences HAS/MISSING/UNKNOWN (that stays weaponMain's job).
--
-- Last-known display layer:
--   THE IRON RULE: last-known data is DISPLAY-ONLY. It never counts as
--   HAS/MISSING in gaps, ready pills, sums, footer or report -- those keep
--   treating the cell as UNKNOWN. A flask seen 20 minutes ago is not
--   verified preparation.
--   * build() gains an OPTIONAL 5th param `now` (number|nil; nil => no
--     ages computed). Matrix passes GetTime(); report/offline callers may
--     omit it.
--   * For every cell whose state is UNKNOWN, when the player carries
--     matching last* data (p.lastAuras/p.lastGear/p.lastWeaponMain --
--     attached by scan/engine.lua from the runtime cache, or by core/sim),
--     THAT data is classified with the SAME classify() rules via a light
--     synthetic player view (no duplicated state machine) and the result
--     rides as cell.last = { state, detail, icon, item, age } (age =
--     now - <At> when both known). last.state can be HAS or MISSING --
--     purely informational; a cached UNKNOWN/NOTEXP outcome attaches
--     nothing (the plain gray "?" stays). Debuff cells have no last-known
--     channel.
--   * rowVM.gearUnreadable = (tab=="EQUIPMENT" and readable and not
--     player.gearRead) -- the cross-continent inspect-limit case.
--     rowVM.unreadNote = "offline" for offline rows, an "items not
--     readable" observation for
--     gearUnreadable rows, nil otherwise.
--
-- Per-class expectations + DEBUFFS warning semantics:
--   * Expectations are PER (role, class): expect[role][class][slotId].
--     build() resolves each row's line via (row role, player.class); an
--     UNKNOWN combination -- role or class line missing -- falls back to
--     the DC.DEFAULT_EXPECT role line. classify()'s
--     signature is unchanged: the CALLER resolves `expected`.
--     Dynamic columns: a slot column is active when ANY (role,
--     performing class) resolves to expected -- the domain is
--     DC_Roles.ROLE_CLASSES, and uncovered combos contribute their
--     fallback line. Corollary: a legacy role x slot table passed as
--     `expect` behaves exactly like before (every combo falls back to
--     its role line).
--   * DEBUFFS = WARNING SIGNAL, not expectation fulfillment:
--     rowVM.sumHas/sumExpected stay NIL on DEBUFFS (no x/y); instead
--     rowVM.debuffCount = TOTAL count of distinct debuff names on the
--     player (player.debuffs.names -- INCLUDING names beyond the
--     DEBUFF_CAP columns), nil when the row is not readable; and
--     group.debuffTotal = sum of debuffCount over the group's readable
--     rows. Gap semantics stay unchanged INTERNALLY: afflicted cells
--     keep driving row sort weight, footer, report and the ready pill.
--     (debuffCount/debuffTotal are computed on every tab -- they are
--     player properties -- the UI reads them on DEBUFFS.)
--   * reportLines section header: the DEBUFFS tab says
--     "Debuffs:", every other tab keeps "Missing per slot:".

DopingControl = DopingControl or {}
local DC = DopingControl

DC_Model = DC_Model or {}
local M = DC_Model

-- Max report line length; the client chat limit is 255, we stay below.
M.MAX_LINE = 240

-- Max dynamic debuff columns on the DEBUFFS tab.
M.DEBUFF_CAP = 10

-- ==================================================================
-- Hit columns (HIT tab).
-- The hit ARITHMETIC lives in scan/hit.lua (DC_Hit.SkillFor / MeleeCap /
-- RangedCap / TalentHit, all pure) and the constants in data/hit.lua and
-- data/talenthit.lua -- this layer only resolves which number a cell
-- shows and whether the cap is met, so there is exactly ONE
-- implementation of the cap math in the addon.
-- Every DC_Hit call is existence-guarded: without that module the cells
-- still show their percentages, they just make no cap claim (a degraded
-- claim is worse than none). Without data/hit.lua the ranged column
-- cannot tell a bow from a wand either and stays NOTEXP throughout --
-- again silence rather than a guess.
-- ==================================================================

-- Percentages are carried with ONE decimal everywhere (cells, caps,
-- averages, report) -- rounding once keeps display and capMet comparison
-- consistent (a 7.96 that renders as "8.0" must also count as capped).
-- One-decimal text for a hit percentage, without the sign or unit --
-- shared by the whisper text so a whispered number reads exactly like the
-- one in the cell the player is being told about.
-- ------------------------------------------------------------------
-- Which hit columns a player is actually asked about.
--
-- Every player HAS all eight hit columns -- a warrior's shadow hit is a
-- real number, it is simply 0. Telling that warrior "shadow 0.0/16.0" is
-- noise, so the whisper (and the below-cap counter that offers it) only
-- looks at the columns that match the ROLE's attack kind. Heals cannot
-- miss, so healers are asked about nothing at all.
--
-- This governs WHISPERING only. Every column stays visible and keeps its
-- own cap marking on the tab -- the display never hides a number because
-- a role "should not care" about it.
-- ------------------------------------------------------------------
M.HIT_ROLE_KINDS = {
    TANK   = { melee = true },
    MELEE  = { melee = true },
    RANGED = { melee = true, ranged = true },
    CASTER = { spell = true },
    HEALER = {},
}

function M.HitRelevant(role, slotdef)
    local kinds = role and M.HIT_ROLE_KINDS[role]
    if not kinds or not slotdef then
        return false
    end
    return kinds[slotdef.hitKind] == true
end

function M.HitNum(v)
    return string.format("%.1f", M.RoundTenth(v or 0))
end

function M.RoundTenth(v)
    if type(v) ~= "number" then
        return nil
    end
    return math.floor(v * 10 + 0.5) / 10
end

-- Display text for a hit percentage: always one decimal ("8.0", "12.5"),
-- so a column of hit numbers stays visually aligned. The UI and
-- reportLines both go through this.
function M.HitText(v)
    if type(v) ~= "number" then
        return "?"
    end
    return string.format("%.1f", v)
end

-- One talent contribution, gated by the read flag. An unread talent tree
-- contributes NOTHING -- never a guess -- which is exactly what keeps the
-- displayed number and the tooltip's "excludes talents" sentence from
-- contradicting each other.
local function talentValue(v, talentsRead)
    if not talentsRead then
        return 0
    end
    return tonumber(v) or 0
end

-- One school's bonus out of ONE source table.
-- The same school reaches this layer in two spellings, because two
-- sources do: gear extras are keyed by the capitalized tooltip word
-- ("Frost"), slot definitions and talent tables by the lower-case id
-- ("frost"). DC.HIT_SCHOOL_KEY (data/hit.lua) is the bridge and is
-- consulted first; the case-insensitive fallbacks below keep this pure
-- layer working when that table is absent (offline tests, partial load).
-- The FIRST match wins -- never a sum -- so a table that happens to carry
-- both spellings can not double-count. Missing table, missing key or a
-- non-numeric value all read as 0.
-- Why not DC_Hit.SchoolHit (scan/hit.lua): that helper answers "what is
-- this school worth in total", while the cell tooltip has to name each
-- SOURCE apart (gear vs talents), so the two amounts are looked up
-- separately here and only summed for display.
local function schoolAmount(tbl, school)
    if type(tbl) ~= "table" or type(school) ~= "string" or school == "" then
        return 0
    end
    local v = tbl[school]
    if type(v) == "number" then
        return v
    end
    local lower = string.lower(school)
    local bridged = DC.HIT_SCHOOL_KEY and DC.HIT_SCHOOL_KEY[lower]
    if bridged then
        v = tbl[bridged]
        if type(v) == "number" then
            return v
        end
    end
    v = tbl[lower]
    if type(v) == "number" then
        return v
    end
    v = tbl[string.upper(string.sub(school, 1, 1))
        .. string.lower(string.sub(school, 2))]
    if type(v) == "number" then
        return v
    end
    for k, n in pairs(tbl) do
        if type(k) == "string" and type(n) == "number"
           and string.lower(k) == lower then
            return n
        end
    end
    return 0
end

-- Effective weapon skill for a hit column, resolved at DISPLAY time:
--   * a MEASURED skill wins (only the own character has one -- scan/hit
--     .lua reads it from the skill window and sets the exact flag),
--   * everyone else gets DC_Hit.SkillFor's estimate = base + racial
--     specialization + weapon-skill book.
-- Resolving here rather than trusting a value the scan wrote is
-- deliberate: the book flags are user input, so a checkbox click must
-- take effect on the next refresh, not on the next scan. Re-deriving the
-- estimate is also idempotent -- it never adds a book twice, whatever the
-- scan already filled in.
-- Each column passes its OWN measured triple (the main hand's skill has
-- no bearing on a bow) and its own subtype.
-- Returns skill, exact(bool), skillName -- all nil/false when the subtype
-- is no weapon at all (nothing equipped, shield, relic): no skill claim.
-- `books` is ONE player's flag table (db.skillBooks[playerName]).
local function effectiveSkill(player, subType, books, measured, measuredExact,
                              measuredName)
    if measuredExact and measured then
        return measured, true, measuredName
    end
    if DC_Hit and DC_Hit.SkillFor then
        local skill, skillName = DC_Hit.SkillFor(subType, player.race, books)
        if skill then
            return skill, false, skillName
        end
    end
    return nil, false, nil
end

-- ==================================================================
-- Data indices, built lazily from DC.CONSUMABLES / DC.CLASSBUFFS
-- (data/*.lua load before first use; call M.RebuildIndex() after
-- mutating the data tables, e.g. in tests).
-- ==================================================================
local indexBuilt = false
local nameHasIds = {}   -- [buffName] = true when the table entry has known spell IDs
local idToName   = {}   -- [spellID]  = buffName (for HAS detail on ID match)

function M.RebuildIndex()
    nameHasIds = {}
    idToName = {}
    if DC.CONSUMABLES then
        for name, def in pairs(DC.CONSUMABLES) do
            if def.spellID and def.spellID > 0 then
                nameHasIds[name] = true
                idToName[def.spellID] = name
            end
        end
    end
    if DC.CLASSBUFFS then
        for name, def in pairs(DC.CLASSBUFFS) do
            if def.ids and table.getn(def.ids) > 0 then
                nameHasIds[name] = true
                local n = table.getn(def.ids)
                for i = 1, n do
                    idToName[def.ids[i]] = name
                end
            end
        end
    end
    indexBuilt = true
end

local function ensureIndex()
    if not indexBuilt then
        M.RebuildIndex()
    end
end

local function nameMapsToSlot(name, slotId)
    local c = DC.CONSUMABLES and DC.CONSUMABLES[name]
    if c and c.slot == slotId then
        return true
    end
    local b = DC.CLASSBUFFS and DC.CLASSBUFFS[name]
    if b and b.slot == slotId then
        return true
    end
    return false
end

-- ==================================================================
-- classify(player, slotdef, expected, books)
--   -> state, detail, icon, item, value
-- State machine per slotdef.kind (icon/item/value extra returns per the
-- cell shape documented in the header).
-- `books` is ONE player's weapon-skill-book flags
-- (db.skillBooks[playerName]) and is read by the hit kind ONLY -- every
-- other kind ignores it, so three-argument callers keep working.
-- NOTEXP takes precedence over UNKNOWN (an unexpected slot is "not
-- expected" even for an unreadable player) for the KINDS that consult
-- `expected` at all -- debuff/resist ignore it outright (see their
-- branches), and ench/trinket consult it LATER than every other kind
-- (see the ench/trinket branch below: gear facts -- readability, class
-- exemption, EMPTY -- are resolved FIRST, `expected` only ever decides
-- the narrower "is this item enchanted" question).
-- ==================================================================
function M.classify(player, slotdef, expected, books)
    local S = DC.STATE
    local kind = slotdef.kind

    if kind == "debuff" then
        -- Expectations are NOT consulted on debuff columns
        -- (every column applies to every role) -- the `expected` param
        -- is deliberately ignored, so this branch sits BEFORE the
        -- expected-check. Readability = the existing aurasRead flag.
        if not player.aurasRead then
            return S.UNKNOWN, player.unreadableReason or "no data"
        end
        local d = player.debuffs
        if d and d.names and d.names[slotdef.id] then
            -- afflicted: icon = debuff texture (player's own entry wins,
            -- column icon as fallback); the ONE non-HAS icon case
            local icon = slotdef.icon
            if d.textures and d.textures[slotdef.id] then
                icon = d.textures[slotdef.id]
            end
            return S.MISSING, "Afflicted: " .. slotdef.full, icon
        end
        -- zero debuffs / absent debuffs field on a readable player is a
        -- LEGITIMATE clean state -- never read failure
        return S.HAS, nil
    end

    if kind == "resist" then
        -- RESIST tab: display only, expectations are NEVER consulted (the
        -- `expected` param is deliberately ignored, like debuff above).
        -- Readability rides on its OWN flag (player.resistsRead) -- fully
        -- independent of aurasRead/gearRead, since a foreign
        -- UnitResistance read is neither an aura nor a gear read.
        if not player.resistsRead then
            return S.UNKNOWN, player.unreadableReason or "no data"
        end
        local v = (player.resists and player.resists[slotdef.school]) or 0
        -- NEVER MISSING, NEVER NOTEXP -- a display tab has no gaps; the
        -- resistance total rides as the 5th return (value), copied onto
        -- cell.value by build() (see the cell shape doc in the header).
        --
        -- Provenance suffix (binding honesty rule, see scan/engine.lua's
        -- AssembleResists header): a RECONSTRUCTED number is not the same
        -- kind of fact as a MEASURED one. player.resistsSource == "gear"
        -- means this total came from item tooltips + the racial bonus, not
        -- from UnitResistance/GetUnitField -- the detail string says so
        -- rather than presenting a rebuilt number as an unqualified
        -- reading. Every other source ("token"/"guid"/"field", or nil for
        -- the always-measured own character) prints unchanged.
        local detail = slotdef.full .. ": " .. v
        if player.resistsSource == "gear" then
            detail = detail .. " (from gear)"
        end
        return S.HAS, detail, nil, nil, v
    end

    if kind == "hit" then
        -- HIT tab, display only: expectations are NEVER consulted (the
        -- `expected` param is ignored, like debuff/resist above), and no
        -- outcome can ever be MISSING -- a hit number below its cap is
        -- information, not a gap the addon nags about.
        local h = player.hit
        if not (h and h.hitRead) then
            -- no hit API exists on this client; without readable item
            -- tooltips there is no number at all (never a 0)
            return S.UNKNOWN, player.unreadableReason or "no data"
        end
        local caps = DC.HIT_CAPS or {}
        local hitKind = slotdef.hitKind or "melee"
        -- Talents: readable for the OWN character only, so the flag is
        -- read strictly (see the header). Anything but true keeps every
        -- talent contribution at 0 AND makes the tooltip say the number
        -- excludes talents -- one flag drives both.
        local talentsRead = (h.talentsRead == true)
        local b = {
            kind = hitKind,
            skillBase = DC.BASE_WEAPON_SKILL or 300,
            mainSub = h.mainSub,
            offSub = h.offSub,
            -- hit-granting auras (DC.HIT_AURAS, data/hit.lua) are folded
            -- into hit.gearX/hit.auraX at scan time (scan/engine.lua
            -- BuildPlayer) -- b.aura carries just the AURA portion so the
            -- tooltip can show "from gear" and "from auras" apart. A
            -- missing aurasRead flag (older/hand-built stores) reads as
            -- true: the aura contribution is a known zero, not unknown.
            aurasRead = (h.aurasRead ~= false),
            talentsRead = talentsRead,
        }
        if hitKind == "spell" then
            b.gear = h.gearSpell or h.spell or 0
            -- SET bonuses are their own addend: scan/engine.lua already
            -- took them OUT of gearSpell, so adding them here counts them
            -- exactly once and lets the tooltip list them on their own line
            b.set = h.setSpell or 0
            b.aura = h.auraSpell or 0
            b.talent = talentValue(h.talentSpell, talentsRead)
            -- the school-INDEPENDENT part: what every school column has in
            -- common, printed as its own tooltip line there
            b.generic = M.RoundTenth(b.gear + b.set + b.aura + b.talent)
            -- spell hit knows no weapon skill: base cap, unadjusted. The
            -- cap itself is DERIVED from the server's 99% hit clamp (data/
            -- hit.lua DC.HIT_CAPS header), not a measured value, but that
            -- is no longer flagged as "unverified" -- see the header note.
            b.capBase = M.RoundTenth(caps.spell)
            b.cap = b.capBase
            if slotdef.school then
                -- School column: generic spell hit + this school's gear
                -- bonus + this school's talent bonus. That sum is the
                -- whole reason the tab exists -- two casters of one class
                -- differ per school. b.schools stays nil here: every
                -- other school has its own column.
                b.school = slotdef.school
                -- h.schools is the TOTAL per school (items + set bonuses),
                -- so the set part is split out here too -- otherwise a
                -- school-specific set bonus would land in both lines
                b.setSchool = schoolAmount(h.setSchools, slotdef.school)
                b.gearSchool = schoolAmount(h.schools, slotdef.school)
                    - b.setSchool
                b.talentSchool = 0
                if talentsRead then
                    b.talentSchool = schoolAmount(h.talentSchools,
                        slotdef.school)
                end
                b.value = M.RoundTenth(b.generic + b.gearSchool
                    + b.setSchool + b.talentSchool)
            else
                -- Generic spell column (no school on the slot): a
                -- school-specific bonus counts against that school ONLY,
                -- so it never enters the value; the whole table rides
                -- along for the tooltip (by reference, the model never
                -- copies store tables).
                b.value = b.generic
                b.schools = h.schools
            end
        else
            -- one gear number for melee AND ranged: this client has no
            -- separate ranged-hit item stat
            b.gear = h.gearMelee or h.melee or 0
            b.set = h.setMelee or 0
            if hitKind == "ranged" then
                local ranged = DC.RANGED_ATTACK_SUBTYPE or {}
                if not (h.rangedSub and ranged[h.rangedSub]) then
                    -- Readable, but there is no ranged-hit question to
                    -- ask. The two causes get their OWN reason, because
                    -- the wand sentence is a non-sequitur for a player
                    -- who simply carries nothing there -- and this string
                    -- is what the cell tooltip prints verbatim.
                    if h.rangedSub then
                        return S.NOTEXP,
                            "a wand or relic is not a ranged attack weapon"
                    end
                    return S.NOTEXP, "nothing in the ranged slot"
                end
                b.aura = h.auraRanged or 0
                b.talent = talentValue(h.talentRanged, talentsRead)
                b.value = M.RoundTenth(b.gear + b.set + b.aura + b.talent)
                b.rangedSub = h.rangedSub
                -- the ranged skill is its OWN skill line (bows/guns/...),
                -- so the measured MAIN-HAND skill must not leak into it
                b.skill, b.skillExact, b.skillName =
                    effectiveSkill(player, h.rangedSub, books,
                        h.rangedSkill, h.rangedSkillExact, nil)
                b.capBase = M.RoundTenth(caps.ranged)
                if DC_Hit and DC_Hit.RangedCap then
                    local rc = DC_Hit.RangedCap(h.rangedSub, b.skill, caps)
                    if rc then
                        b.cap = rc.cap
                        b.skillName = b.skillName or rc.skillName
                    end
                end
            else
                b.aura = h.auraMelee or 0
                b.talent = talentValue(h.talentMelee, talentsRead)
                -- Off-hand hit talent (the warrior dual-wield talent):
                -- it raises OFF-HAND swings only, so it is deliberately
                -- NOT added to the melee number -- the tooltip mentions
                -- it while the player actually holds two weapons.
                b.talentOffhand = talentValue(h.talentOffhand, talentsRead)
                b.value = M.RoundTenth(b.gear + b.set + b.aura + b.talent)
                b.skill, b.skillExact, b.skillName =
                    effectiveSkill(player, h.mainSub, books,
                        h.skill, h.skillExact, h.skillName)
                b.skillName = b.skillName or h.skillName
                b.capBase = M.RoundTenth(caps.meleeYellow)
                if DC_Hit and DC_Hit.MeleeCap then
                    local mc = DC_Hit.MeleeCap(h.mainSub, h.offSub,
                        b.skill, caps)
                    b.cap = mc.yellow
                    b.dualWield = mc.dualWield
                    if mc.dualWield then
                        -- white swings keep their own, much higher cap.
                        -- Stated at its BASE too (see the skill note
                        -- below): the boss's floor does not move.
                        b.capWhite = M.RoundTenth(caps.meleeWhiteDW)
                    end
                    b.skillName = b.skillName or mc.skillName
                end
            end
            -- did a ticked book go into this column's skill? (a MEASURED
            -- skill already contains it, the tooltip says so instead)
            b.book = (books and b.skillName and books[b.skillName])
                and true or false
        end
        -- WEAPON SKILL counts on the PLAYER's side of the comparison, not
        -- as a discount on the boss's requirement. The two readings are
        -- arithmetically identical --
        --     value >= cap - d   <=>   value + d >= cap
        -- -- but a +3-level boss's miss floor is a property of the BOSS and
        -- does not move because the attacker trained a weapon; skill is
        -- something the player brings, exactly like gear or a talent. So
        -- the skill-derived part becomes an addend of the value and the cap
        -- is reported at its base. b.skillHit carries it so the tooltip can
        -- show it as its own row of the addition.
        if b.cap and b.capBase then
            b.skillHit = M.RoundTenth(b.capBase - b.cap)
            if b.skillHit ~= 0 then
                b.value = M.RoundTenth(b.value + b.skillHit)
            end
            b.cap = b.capBase
        else
            b.skillHit = 0
        end
        if b.cap == nil then
            -- no cap claim without the hit module (see the section
            -- header) -- the number itself is still shown
            b.capMet = false
        else
            b.capMet = b.value >= b.cap
        end
        return S.HAS, b, nil, nil, b.value
    end

    if kind == "ench" or kind == "trinket" then
        -- Equipment: gaps are shaped by what is EQUIPPED, not by the
        -- expectation grid. Order: class exemption (ench only, wins over
        -- everything -- e.g. a shaman never has a ranged enchant slot,
        -- readable or not), then gearRead/UNKNOWN, then whether the slot
        -- is EMPTY (always MISSING except the legitimately-empty
        -- exemptable case -- see the header). `expected` is consulted
        -- LAST and ONLY for the ench-kind "is this item enchanted"
        -- question; trinket-kind tiles never read `expected` at all --
        -- an equipped item is HAS, an empty tile is MISSING, always.
        if kind == "ench" then
            local classExempt = DC.ENCH_CLASS_EXEMPT
                and DC.ENCH_CLASS_EXEMPT[slotdef.id]
            if classExempt and player.class and classExempt[player.class] then
                return S.NOTEXP, "no ranged enchant for this class"
            end
        end
        if not player.gearRead then
            return S.UNKNOWN, player.unreadableReason or "no data"
        end
        local entry = player.gear and player.gear[slotdef.invSlot]
        if entry == nil then
            return S.UNKNOWN, "no data"
        end
        local exempt = DC.ENCH_EXEMPT and DC.ENCH_EXEMPT[slotdef.id]
        if entry == "EMPTY" then
            if kind == "ench" and exempt then
                -- exemptable slot (off hand / ranged) with no item:
                -- nothing to enchant, and the item itself would have been
                -- exempt anyway -- legitimately empty, not a gap
                return S.NOTEXP, "no item"
            end
            -- every other empty slot -- armor, the enchantable neck/ring
            -- slots, and the trinket tiles -- is ALWAYS a gap, no matter
            -- what the expectation grid says (see the header)
            return S.MISSING, slotdef.full .. " empty"
        end
        -- entry is a table from here on -> it rides along as cell.item
        -- (by reference; the model never copies gear entries)
        if kind == "trinket" then
            -- tiles ignore `expected` entirely: an equipped item is HAS
            return S.HAS, entry, nil, entry
        end
        if exempt and entry.subType and exempt[entry.subType] then
            return S.NOTEXP, entry.subType .. " - not enchantable", nil, entry
        end
        if not expected then
            -- enchant not expected for this role/class: the item is real
            -- (not empty), so the tile still renders -- NOTEXP carries
            -- the item, counts nowhere, no green/red marker (ui/matrix.lua)
            return S.NOTEXP, nil, nil, entry
        end
        if entry.enchant and entry.enchant > 0 then
            return S.HAS, entry.name, nil, entry
        end
        return S.MISSING, "no enchant", nil, entry
    end

    if not expected then
        return S.NOTEXP, nil
    end

    if kind == "aura" then
        if not player.aurasRead then
            return S.UNKNOWN, player.unreadableReason or "no data"
        end
        ensureIndex()
        local auras = player.auras or {}
        local ids = auras.ids or {}
        local names = auras.names or {}
        local textures = auras.textures   -- optional store field, nil-safe
        local spellToSlot = DC.SPELL_TO_SLOT or {}
        -- ID pass: spell ID is the primary identity
        for id in pairs(ids) do
            if spellToSlot[id] == slotdef.id then
                local nm = idToName[id]
                if nm then
                    local icon = nil
                    if textures then
                        icon = textures[nm]
                    end
                    return S.HAS, nm .. " (spell " .. id .. ")", icon
                end
                -- no name carried the id -> icon stays nil (by design)
                return S.HAS, "spell " .. id
            end
        end
        -- Name fallback: when IDs were readable, only names WITHOUT known
        -- IDs still count (name-collision rule: the Shadow Protection
        -- potion aura must not satisfy the SPROT class-buff slot).
        local haveIds = (next(ids) ~= nil)
        for name in pairs(names) do
            if nameMapsToSlot(name, slotdef.id) then
                if not (haveIds and nameHasIds[name]) then
                    -- Consumable/class-buff name collision (decision
                    -- 2026-08-15, data/classbuffs.lua
                    -- DC.CONSUMABLE_CLASSBUFF_NAME_COLLISION): a
                    -- name-only match (this branch only runs when no
                    -- spell ID resolved the buff) can never satisfy a
                    -- CONSUMABLE slot when the name is also a class buff
                    -- name -- UNKNOWN, not HAS. One-sided: the class-buff
                    -- slot side of the same name is unaffected (see the
                    -- constant's comment for why).
                    local cdef = DC.CONSUMABLES and DC.CONSUMABLES[name]
                    local collide = DC.CONSUMABLE_CLASSBUFF_NAME_COLLISION
                    if cdef and cdef.slot == slotdef.id
                       and collide and collide[name] then
                        return S.UNKNOWN,
                            "name-only, ambiguous with class buff: " .. name
                    end
                    local icon = nil
                    if textures then
                        icon = textures[name]
                    end
                    return S.HAS, name, icon
                end
            end
        end
        return S.MISSING, nil

    elseif kind == "weapon" then
        if player.weaponMain == nil then
            return S.UNKNOWN, player.unreadableReason or "no data"
        end
        if player.weaponMain then
            -- enchant name when the scan delivered one
            if player.weaponMainName then
                return S.HAS, player.weaponMainName
            end
            return S.HAS, "main hand temp enchant"
        end
        return S.MISSING, nil
    end

    return S.UNKNOWN, "unknown slot kind"
end

-- ==================================================================
-- Gap label for chat output (whisper + report):
-- consumables/class buffs -> full slot name; enchant -> "Enchant <Slot>";
-- trinket -> "Trinket N empty".
-- ==================================================================
function M.gapLabel(slotdef)
    if slotdef.kind == "ench" then
        return "Enchant " .. slotdef.full
    elseif slotdef.kind == "trinket" then
        return slotdef.full .. " empty"
    end
    return slotdef.full
end

-- ==================================================================
-- build(store, roleOf, expect, tab, now, books) -> vm
--   roleOf(playerEntry) -> role, suggested(bool), reason(string|nil)
--   expect = per-class expectation table from db:
--            expect[role][class][slotId] = true|nil; unknown (role,
--            class) combos fall back to the DC.DEFAULT_EXPECT role line
--   tab    = "CONSUMABLES" | "CLASSBUFFS" | "DEBUFFS" | "RESIST" |
--            "HIT" | "EQUIPMENT"
--   now    = optional clock (GetTime seconds) for cell.last.age -- nil
--            => no ages computed
--   books  = optional db.skillBooks (weapon-skill-book flags per player
--            NAME, then per weapon skill). Read by the hit columns only;
--            nil => nobody owns a book. Passed in rather than read from a
--            global so the model stays offline-testable, and resolved on
--            every build so a checkbox click shows up on the next refresh
--            instead of the next scan.
--   vm.unassigned = { count = n, names = sorted array } -- players whose
--     role is only a heuristic suggestion (suggested==true), i.e. no
--     confirmed assignment. Counts ALL players, readable or not: role
--     assignment is independent of readability.
-- ==================================================================
local function tabSlots(tab)
    if tab == "CLASSBUFFS" then
        return DC.SLOTS_CLASSBUFFS
    elseif tab == "EQUIPMENT" then
        return DC.SLOTS_EQUIPMENT
    end
    return DC.SLOTS_CONSUMABLES
end

-- DISPLAY tabs: static columns, no expectations, no gaps, no sums, no
-- whisper, a per-column average instead of a missing count. Named once so
-- the four places that implement those rules cannot drift apart when a
-- third display tab appears.
function M.IsDisplayTab(tab)
    return tab == "RESIST" or tab == "HIT"
end

-- ==================================================================
-- ChannelRead(player, tab) -> bool
-- Did the read channel THIS TAB DRAWS FROM succeed for this player?
-- Row readability (and every cell state) rides on the aura channel,
-- which is raid-wide; gear and hit are near-range only, and foreign
-- resistances are not transmitted at all, so one global count
-- over-reports on every tab that is not aura-backed. Measured in a
-- 26-man raid: auras 26/26, gear 15/26, resistances 1/26 while the
-- pill said 26/26 everywhere.
-- The offline gate comes first on purpose: a player who dropped out
-- keeps whatever flags the last successful scan left behind, and those
-- must not re-inflate the number that exists to stay honest.
-- Deliberately NOT wired into row.readable -- that flag decides cell
-- states and sums, and turning it per-tab would silently rewrite what
-- UNKNOWN means on three tabs. This is the reporting number only.
-- ==================================================================
function M.ChannelRead(p, tab)
    if p.online == false then
        return false
    end
    if tab == "RESIST" then
        return p.resistsRead and true or false
    elseif tab == "HIT" then
        return (p.hit and p.hit.hitRead) and true or false
    elseif tab == "EQUIPMENT" then
        return p.gearRead and true or false
    end
    return p.aurasRead and true or false
end

-- ==================================================================
-- CoverageLabel(tab) -> string
-- The noun behind the coverage number. "readable" used to sit on the
-- pill AND (as "AVERAGE (READABLE)") in the footer while meaning two
-- different populations; naming the channel on the pill removes the
-- collision instead of papering over it, and says in-game which read
-- the number is about. Plain ASCII -- the 1.12 chat/UI font.
-- ==================================================================
function M.CoverageLabel(tab)
    if tab == "RESIST" then
        return "resist read"
    elseif tab == "HIT" then
        return "hit read"
    elseif tab == "EQUIPMENT" then
        return "gear read"
    end
    return "readable"
end

-- ------------------------------------------------------------------
-- Per-(role,class) expectation resolution.
-- expect[role][class] is the explicit line; a missing role or class
-- line falls back to the DC.DEFAULT_EXPECT role line, so
-- saved tables that predate a class/role addition keep sane behavior.
-- classify() stays untouched: these helpers run in the CALLER.
-- ------------------------------------------------------------------
local EMPTY_LINE = {}

local function expectLine(expect, role, class)
    local byClass = expect and expect[role]
    if byClass and class then
        local line = byClass[class]
        if type(line) == "table" then
            return line
        end
    end
    local def = DC.DEFAULT_EXPECT and DC.DEFAULT_EXPECT[role]
    return def or EMPTY_LINE
end

-- A slot column is active when ANY (role, performing class) resolves to
-- expected. The domain is role x DC_Roles.ROLE_CLASSES
-- [role] -- resolved with the same fallback as row cells, so uncovered
-- combos keep their default columns visible.
local function anyClassExpects(expect, slotId)
    local nRoles = table.getn(DC.ROLES)
    for r = 1, nRoles do
        local role = DC.ROLES[r]
        local classes = DC_Roles and DC_Roles.ROLE_CLASSES
            and DC_Roles.ROLE_CLASSES[role]
        if classes then
            for c = 1, table.getn(classes) do
                if expectLine(expect, role, classes[c])[slotId] then
                    return true
                end
            end
        else
            -- defensive: without DC_Roles fall back to the role line
            if expectLine(expect, role, nil)[slotId] then
                return true
            end
        end
    end
    return false
end

-- Dynamic DEBUFFS columns: one column per distinct
-- debuff name across READABLE players (same readability rule as the row
-- loop), sorted by afflicted count desc then alpha, capped at
-- M.DEBUFF_CAP. Returns slots array + overflow count (distinct beyond
-- the cap; the report appends "+N more debuff types" from it).
local function buildDebuffSlots(store)
    local counts = {}
    local icons = {}
    local names = {}
    local players = (store and store.players) or {}
    local nPlayers = table.getn(players)
    for i = 1, nPlayers do
        local p = players[i]
        -- readability rule: offline / aurasRead==false
        -- players contribute no columns
        if p.online ~= false and p.aurasRead
           and p.debuffs and p.debuffs.names then
            for nm in pairs(p.debuffs.names) do
                if counts[nm] == nil then
                    counts[nm] = 0
                    table.insert(names, nm)
                end
                counts[nm] = counts[nm] + 1
                if icons[nm] == nil and p.debuffs.textures then
                    icons[nm] = p.debuffs.textures[nm]
                end
            end
        end
    end
    table.sort(names, function(a, b)
        if counts[a] ~= counts[b] then
            return counts[a] > counts[b]
        end
        return a < b
    end)
    local total = table.getn(names)
    local keep = total
    if keep > M.DEBUFF_CAP then
        keep = M.DEBUFF_CAP
    end
    local slots = {}
    for i = 1, keep do
        local nm = names[i]
        table.insert(slots, {
            id = nm, label = nil, full = nm,
            kind = "debuff", icon = icons[nm],
        })
    end
    return slots, total - keep
end

-- Group-internal sort: gaps first (gaps desc), then unreadable, then
-- complete; ties stable by name (table.sort is NOT stable in Lua 5.0,
-- so name is an explicit final tiebreaker -> total deterministic order).
local function rowLess(a, b)
    if a.sortBucket ~= b.sortBucket then
        return a.sortBucket < b.sortBucket
    end
    if a.sortBucket == 1 and a.gaps ~= b.gaps then
        return a.gaps > b.gaps
    end
    return a.player.name < b.player.name
end

function M.build(store, roleOf, expect, tab, now, books)
    local S = DC.STATE
    local slots
    local debuffMore = 0

    if tab == "DEBUFFS" then
        -- columns come from the STORE, not from a data table, and
        -- expectations play no part
        slots, debuffMore = buildDebuffSlots(store)
    elseif tab == "RESIST" then
        -- static columns, always all 5, expectations play no part
        -- (display-only tab -- see the header)
        slots = DC.SLOTS_RESIST or {}
    elseif tab == "HIT" then
        -- static columns, always all 8 (melee, ranged, six schools) --
        -- the second display-only tab, same rules throughout
        slots = DC.SLOTS_HIT or {}
    else
        local allSlots = tabSlots(tab)
        -- Dynamic columns: a slot is active
        -- iff at least one (role, performing class) expects it; trinkets
        -- are ALWAYS active on EQUIPMENT.
        slots = {}
        local nAll = table.getn(allSlots)
        for i = 1, nAll do
            local sd = allSlots[i]
            local active = false
            if sd.kind == "trinket" then
                active = true
            else
                active = anyClassExpects(expect, sd.id)
            end
            if active then
                table.insert(slots, sd)
            end
        end
    end
    local nSlots = table.getn(slots)

    -- Groups: always all five, fixed order.
    local groups = {}
    local groupsByRole = {}
    local nRoles = table.getn(DC.ROLES)
    for r = 1, nRoles do
        local role = DC.ROLES[r]
        local g = {
            role = role,
            label = DC.ROLE_LABEL[role],
            rows = {},
            total = 0,
            readable = 0,
            unreadable = 0,
            gaps = 0,
            ready = 0,
            -- sum of debuffCount over readable rows
            debuffTotal = 0,
        }
        table.insert(groups, g)
        groupsByRole[role] = g
    end

    -- Footer: one entry per ACTIVE slot (count 0 included).
    local footer = {}
    for i = 1, nSlots do
        footer[slots[i].id] = { count = 0, names = {} }
    end

    -- readable = the aura-channel/row-readability count (unchanged, it
    -- backs the cell states); channelReadable = what THIS tab's own read
    -- channel actually covers -- see M.ChannelRead
    local coverage = { readable = 0, total = 0, offline = 0,
                       readfailed = 0, far = 0, channelReadable = 0 }
    local unreadableNames = {}
    local unassignedNames = {}

    local players = store.players or {}
    local nPlayers = table.getn(players)
    coverage.total = nPlayers

    for i = 1, nPlayers do
        local p = players[i]

        -- Readability rule (binding): online==false =>
        -- offline; otherwise aurasRead==false => read failed. Distance is
        -- diagnosis only, never a filter.
        local readable, urReason
        if p.online == false then
            readable = false
            urReason = p.unreadableReason or "offline"
            coverage.offline = coverage.offline + 1
        elseif not p.aurasRead then
            readable = false
            urReason = p.unreadableReason or "read failed"
            coverage.readfailed = coverage.readfailed + 1
        else
            readable = true
        end
        if readable then
            coverage.readable = coverage.readable + 1
        else
            table.insert(unreadableNames, p.name)
        end
        if M.ChannelRead(p, tab) then
            coverage.channelReadable = coverage.channelReadable + 1
        end

        -- Out-of-range flag: diagnostic ONLY -- far
        -- changes no cell state, no sum, no pill. Unreadable players are
        -- never "far" (their distance is already pure diagnosis).
        local far = false
        if readable and p.distance and DC.FAR_LIMIT
           and p.distance >= DC.FAR_LIMIT then
            far = true
            coverage.far = coverage.far + 1
        end

        local role, suggested, roleReason = roleOf(p)
        if not groupsByRole[role] then
            role = DC.ROLES[1]  -- defensive: roleOf must always return a known role
        end
        -- Unassigned aggregate: suggested == the role is only a heuristic
        -- proposal, not a confirmed assignment. EVERY such player counts,
        -- unreadable ones too (assignment is independent of readability).
        if suggested then
            table.insert(unassignedNames, p.name)
        end
        -- per-class expectation line: explicit
        -- expect[role][player.class] when present, else the
        -- DC.DEFAULT_EXPECT role line (default fallback)
        local rexp = expectLine(expect, role, p.class)
        -- this player's weapon-skill-book flags (hit columns only)
        local pBooks = books and books[p.name] or nil

        -- Last-known display layer: synthetic read-only
        -- player views over the cached tables, built ONCE per row, so the
        -- SAME classify() state machine below can re-run on the cached
        -- data (no duplicated rules). IRON RULE: results only decorate
        -- UNKNOWN cells as cell.last -- no counter below ever reads them.
        local lastAuraView, lastGearView, lastWeaponView
        if p.lastAuras then
            lastAuraView = { name = p.name, class = p.class, online = true,
                aurasRead = true, auras = p.lastAuras }
        end
        if p.lastGear then
            lastGearView = { name = p.name, class = p.class, online = true,
                gearRead = true, gear = p.lastGear }
        end
        if p.lastWeaponMain ~= nil then
            lastWeaponView = { name = p.name, class = p.class, online = true,
                weaponMain = p.lastWeaponMain,
                weaponMainName = p.lastWeaponMainName }
        end

        local cells = {}
        local gaps, unknowns, has = 0, 0, 0
        -- HIT tab only: columns that are SHORT OF THEIR CAP. The hit
        -- tab produces no MISSING cells (a number below its cap is
        -- information, not an unfilled slot), so it needs its own
        -- counter to answer "is there anything to tell this player".
        local belowCap = 0
        for si = 1, nSlots do
            local sd = slots[si]
            local expd = false
            if sd.kind == "debuff" or sd.kind == "resist"
               or sd.kind == "hit" then
                -- expectations are NOT consulted on debuff/resist/hit
                -- columns; classify ignores the flag anyway
                expd = true
            elseif rexp[sd.id] then
                expd = true
            end
            local state, detail, icon, item, value =
                M.classify(p, sd, expd, pBooks)
            cells[sd.id] = { state = state, detail = detail,
                             icon = icon, item = item }
            if sd.kind == "resist" then
                cells[sd.id].value = value
            elseif sd.kind == "hit" then
                -- value/cap/capMet are lifted out of the breakdown table
                -- so the UI can paint a cell without unpacking it; they
                -- stay nil on NOTEXP/UNKNOWN (detail is a string there)
                cells[sd.id].value = value
                if type(detail) == "table" then
                    cells[sd.id].cap = detail.cap
                    cells[sd.id].capMet = detail.capMet
                    -- BELOW-CAP counter -- the hit tab's equivalent of a
                    -- gap, and the only thing that makes a hit row worth
                    -- whispering about. A column with NO cap known makes
                    -- no claim (capMet is false there too), so the cap
                    -- itself has to be present before this counts.
                    if detail.cap and detail.capMet == false
                       and M.HitRelevant(role, sd) then
                        belowCap = belowCap + 1
                    end
                end
            end
            if state == S.MISSING then
                gaps = gaps + 1
                if readable then
                    local f = footer[sd.id]
                    f.count = f.count + 1
                    table.insert(f.names, p.name)
                end
            elseif state == S.UNKNOWN then
                unknowns = unknowns + 1
                -- cell.last: classify the cached data with
                -- the same rules; attach only determinate outcomes (a
                -- cached UNKNOWN/NOTEXP keeps the plain gray "?"). Debuff
                -- kind has no last-known channel (no view is ever built).
                local view, at
                if sd.kind == "aura" then
                    view = lastAuraView
                    at = p.lastAurasAt
                elseif sd.kind == "weapon" then
                    view = lastWeaponView
                    at = p.lastWeaponAt
                elseif sd.kind == "ench" or sd.kind == "trinket" then
                    view = lastGearView
                    at = p.lastGearAt
                end
                if view then
                    local ls, ld, li, lit = M.classify(view, sd, expd)
                    if ls == S.HAS or ls == S.MISSING then
                        local age = nil
                        if now and at then
                            age = now - at
                        end
                        cells[sd.id].last = { state = ls, detail = ld,
                            icon = li, item = lit, age = age }
                    end
                end
            elseif state == S.HAS then
                has = has + 1
            end
        end

        -- total count of DISTINCT debuff names on the
        -- player -- straight off player.debuffs.names, so it INCLUDES
        -- names beyond the DEBUFF_CAP columns (warning signal, not
        -- column arithmetic). nil when the row is not readable; zero
        -- debuffs on a readable player is the verified-clean case.
        local debuffCount = nil
        if readable then
            debuffCount = 0
            if p.debuffs and p.debuffs.names then
                for _ in pairs(p.debuffs.names) do
                    debuffCount = debuffCount + 1
                end
            end
        end

        local row = {
            player = p,
            tab = tab,
            role = role,
            suggested = suggested and true or false,
            reason = roleReason,
            readable = readable,
            unreadableReason = urReason,
            cells = cells,
            gaps = gaps,
            unknowns = unknowns,
            far = far,
            debuffCount = debuffCount,
            -- Class buffs are the BUFFER's duty, not the recipient's:
            -- whispering the player who lacks Arcane Intellect is
            -- pointless. Gaps on that tab go to the raid report,
            -- which already names slot + affected players. Debuffs are
            -- nobody's shopping list either: whisperEligible is ALWAYS
            -- false on DEBUFFS. The display tabs never produce gaps at
            -- all (gaps stays 0 there), but they are excluded explicitly
            -- too -- belt and suspenders, a display tab is never whisper
            -- bait.
            -- HIT is the one display tab a player CAN act on: being
            -- short of the hit cap is fixable by that player (gear,
            -- enchants, a weapon-skill book), unlike a resistance total.
            -- So it gets the whisper button, driven by belowCap instead
            -- of gaps -- RESIST stays silent.
            belowCap = belowCap,
            whisperEligible = readable
                and ((tab == "HIT" and belowCap > 0)
                     or (gaps > 0 and tab ~= "CLASSBUFFS"
                         and tab ~= "DEBUFFS"
                         and not M.IsDisplayTab(tab))),
        }
        -- last-known row flags: an EQUIPMENT row that is readable by
        -- the aura rule but whose gear could not be read (cross-continent
        -- inspect limit) -- the UI renders a row-spanning note instead of
        -- 13 bare "?" cells.
        --
        -- unreadNote states the OBSERVATION, not a guessed cause. It used
        -- to say "out of range", which named one possible reason as if it
        -- were measured: gearRead is false whenever not one of the 16
        -- inventory slots returned a link, and distance is only one way
        -- that happens (no SuperWoW, a failed inspect, a different
        -- continent). The measured distance is printed separately by the
        -- UI, so a reader still gets the range hypothesis -- as a number
        -- they can judge, not as a verdict.
        row.gearUnreadable = false
        if tab == "EQUIPMENT" and readable and not p.gearRead then
            row.gearUnreadable = true
        end
        if p.online == false then
            row.unreadNote = "offline"
        elseif row.gearUnreadable then
            -- SHORT on purpose: this lands in the small status chip next
            -- to the player's name, which has room for a few words. The
            -- reason it is not "out of range" is that distance is only
            -- ONE way the gear read comes back empty -- the full sentence
            -- lives in the row-spanning message and the cell tooltip,
            -- where there is space for it.
            row.unreadNote = "items not readable"
        end
        if readable and tab ~= "DEBUFFS" and not M.IsDisplayTab(tab) then
            -- Sums over determinate cells only: UNKNOWN is excluded from
            -- BOTH numbers (never condensed into sums).
            -- On DEBUFFS and the display tabs the sums stay NIL even for
            -- readable rows (warning signal / display tab, no x/y -- the
            -- sum column renders debuffCount or nothing instead).
            row.sumHas = has
            row.sumExpected = has + gaps
        end
        if not readable then
            row.sortBucket = 2
        elseif gaps > 0 then
            row.sortBucket = 1
        else
            row.sortBucket = 3
        end

        local g = groupsByRole[role]
        table.insert(g.rows, row)
        g.total = g.total + 1
        if readable then
            g.readable = g.readable + 1
            g.gaps = g.gaps + gaps
            g.debuffTotal = g.debuffTotal + (debuffCount or 0)
            -- ready = verified complete: readable, 0 gaps, 0 unknowns
            -- (an UNKNOWN cell never counts toward a green pill)
            if gaps == 0 and unknowns == 0 then
                g.ready = g.ready + 1
            end
        else
            g.unreadable = g.unreadable + 1
        end
    end

    for r = 1, nRoles do
        table.sort(groups[r].rows, rowLess)
    end
    for i = 1, nSlots do
        table.sort(footer[slots[i].id].names)
    end
    table.sort(unreadableNames)
    table.sort(unassignedNames)

    -- Display-tab averages (RESIST + HIT): computed straight off
    -- store.players, independent of row.readable (which tracks aura/
    -- online status -- resistance and hit readability are their own
    -- flags, player.resistsRead and player.hit.hitRead). nil on every
    -- other tab.
    -- Hit columns bring their own denominator per column: a cell that is
    -- not HAS -- an unreadable player, or the ranged column of a player
    -- without a ranged weapon -- is left OUT of the average instead of
    -- being counted as 0. classify() is re-run for those columns so the
    -- average can never drift away from the rendered cells (same trick as
    -- the last-known layer: one state machine).
    local colAvg, colCount, resistReadable, hitReadable
    if M.IsDisplayTab(tab) then
        colAvg = {}
        resistReadable = 0
        hitReadable = 0
        local sums, counts = {}, {}
        -- the DENOMINATOR each average was actually divided by, per
        -- column: it equals resistReadable for the five resistances, but
        -- the ranged hit column skips every NOTEXP player, so a report
        -- that printed hitReadable next to it would name a population the
        -- average never covered
        colCount = counts
        for i = 1, nPlayers do
            local p = players[i]
            if p.resistsRead then
                resistReadable = resistReadable + 1
            end
            local hitRead = (p.hit and p.hit.hitRead) and true or false
            if hitRead then
                hitReadable = hitReadable + 1
            end
            for si = 1, nSlots do
                local sd = slots[si]
                if sd.kind == "hit" then
                    if hitRead then
                        local hs, _, _, _, hv = M.classify(p, sd, true,
                            books and books[p.name] or nil)
                        if hs == S.HAS and hv then
                            sums[sd.id] = (sums[sd.id] or 0) + hv
                            counts[sd.id] = (counts[sd.id] or 0) + 1
                        end
                    end
                elseif p.resistsRead then
                    local v = (p.resists and p.resists[sd.school]) or 0
                    sums[sd.id] = (sums[sd.id] or 0) + v
                    counts[sd.id] = (counts[sd.id] or 0) + 1
                end
            end
        end
        for si = 1, nSlots do
            local sd = slots[si]
            local n = counts[sd.id] or 0
            if n <= 0 then
                colAvg[sd.id] = 0
            elseif sd.kind == "hit" then
                colAvg[sd.id] = M.RoundTenth((sums[sd.id] or 0) / n)
            else
                colAvg[sd.id] = math.floor((sums[sd.id] or 0) / n + 0.5)
            end
        end
    end

    return {
        coverage = coverage,
        slots = slots,
        groups = groups,
        footer = footer,
        unreadable = {
            count = coverage.offline + coverage.readfailed,
            names = unreadableNames,
        },
        unassigned = {
            count = table.getn(unassignedNames),
            names = unassignedNames,
        },
        -- distinct debuff names beyond the DEBUFF_CAP columns (0 outside
        -- the DEBUFFS tab); reportLines appends "+N more debuff types"
        debuffMore = debuffMore,
        -- DISPLAY tabs only (nil elsewhere): rounded average per column
        -- over the players who deliver that column -- resistance readers
        -- for the five schools, hit readers with a HAS cell for the hit
        -- columns -- plus the per-column denominator behind it and the
        -- two channel reader counts.
        colAvg = colAvg,
        colCount = colCount,
        -- aliases (the SAME tables, not copies) for readers written when
        -- the resistances were the only display tab
        resistAvg = colAvg,
        resistCount = colCount,
        resistReadable = resistReadable,
        hitReadable = hitReadable,
        tab = tab,
    }
end

-- ==================================================================
-- whisperText(rowVM, tabLabel, slots) -> string
-- Pure ASCII, plain "-" separator (not an
-- em dash): "DopingControl - missing before pull: Flask, Stamina/HP".
-- Slot order = column order. tabLabel is part of the fixed signature
-- but not of the output string (gap labels are self-explanatory).
-- ==================================================================
function M.whisperText(rowVM, tabLabel, slots)
    local parts = {}
    local n = table.getn(slots)
    -- HIT has no MISSING cells at all -- a number below its cap is
    -- information, not an unfilled slot. So it whispers the SHORTFALL,
    -- with both numbers: "Melee 5.0/8.0" says what to fix and how far,
    -- which a bare slot name could not.
    if rowVM.tab == "HIT" then
        for i = 1, n do
            local sd = slots[i]
            local c = rowVM.cells[sd.id]
            if c and c.cap and c.capMet == false and c.value
               and M.HitRelevant(rowVM.role, sd) then
                table.insert(parts, M.gapLabel(sd) .. " "
                    .. M.HitNum(c.value) .. "/" .. M.HitNum(c.cap))
            end
        end
        return "DopingControl - below the hit cap: "
            .. table.concat(parts, ", ")
    end
    for i = 1, n do
        local sd = slots[i]
        local c = rowVM.cells[sd.id]
        if c and c.state == DC.STATE.MISSING then
            table.insert(parts, M.gapLabel(sd))
        end
    end
    return "DopingControl - missing before pull: " .. table.concat(parts, ", ")
end

-- ==================================================================
-- reportLines(vm, tabLabel, timeStr) -> array of strings
-- Each line <= M.MAX_LINE chars; long name lists continue on
-- "  ... continued:" lines. Pure ASCII ("|" separators --
-- chat output never uses middle dots).
-- ==================================================================
local CONT_PREFIX = "  ... continued: "

local function emitNameList(lines, prefix, names)
    local cur = prefix
    local first = true
    local n = table.getn(names)
    for i = 1, n do
        local nm = names[i]
        if not first and
           string.len(cur) + 2 + string.len(nm) > M.MAX_LINE then
            table.insert(lines, cur)
            cur = CONT_PREFIX .. nm
        elseif first then
            cur = cur .. nm
            first = false
        else
            cur = cur .. ", " .. nm
        end
    end
    table.insert(lines, cur)
end

function M.reportLines(vm, tabLabel, timeStr)
    local lines = {}
    -- the tab's own channel count, not the aura one: the header names a
    -- tab, so the number behind it has to be that tab's (see
    -- M.ChannelRead). Falls back for hand-built vms from older callers.
    table.insert(lines, "DopingControl | " .. timeStr .. " | " .. tabLabel
        .. " | coverage "
        .. (vm.coverage.channelReadable or vm.coverage.readable)
        .. "/" .. vm.coverage.total)

    -- The display tabs are no expectation check -- no missing lists at
    -- all, one average line per column instead. Early return: the
    -- gap-oriented machinery below (order/footer/debuffMore) does not
    -- apply here.
    if M.IsDisplayTab(vm.tab) then
        if vm.tab == "HIT" then
            table.insert(lines, "Hit (average, readable players):")
        else
            table.insert(lines, "Resistances (average, readable players):")
        end
        local nSlots = table.getn(vm.slots)
        for i = 1, nSlots do
            local sd = vm.slots[i]
            local avg = (vm.colAvg and vm.colAvg[sd.id]) or 0
            -- hit columns print percent with one decimal; resistances
            -- print a plain integer
            local avgTxt
            if sd.kind == "hit" then
                avgTxt = M.HitText(avg) .. "%"
            else
                avgTxt = tostring(avg)
            end
            -- The count named here is the population the average was
            -- actually taken over, NOT the channel's reader count: the
            -- ranged hit column drops every player without a ranged
            -- weapon, so quoting the reader count would overstate it.
            local readers
            if vm.colCount and vm.colCount[sd.id] then
                readers = vm.colCount[sd.id]
            elseif sd.kind == "hit" then
                readers = vm.hitReadable or 0
            else
                readers = vm.resistReadable or 0
            end
            local noun = (readers == 1) and " player)" or " players)"
            table.insert(lines, "  " .. sd.full .. ": avg " .. avgTxt
                .. " (" .. readers .. noun)
        end
        if vm.unreadable and vm.unreadable.count > 0 then
            emitNameList(lines,
                "Not readable (" .. vm.unreadable.count .. "): ",
                vm.unreadable.names)
        end
        return lines
    end

    -- DEBUFFS is a warning list, not an expectation check
    -- -- its section header word is "Debuffs:"; the per-debuff name lists
    -- below stay exactly as they are. Expectation tabs keep the pinned
    -- "Missing per slot:".
    if vm.tab == "DEBUFFS" then
        table.insert(lines, "Debuffs:")
    else
        table.insert(lines, "Missing per slot:")
    end

    -- slots with gaps, sorted by count desc; ties keep column order
    local order = {}
    local nSlots = table.getn(vm.slots)
    for i = 1, nSlots do
        local sd = vm.slots[i]
        local f = vm.footer[sd.id]
        if f and f.count > 0 then
            table.insert(order, { sd = sd, f = f, idx = i })
        end
    end
    table.sort(order, function(a, b)
        if a.f.count ~= b.f.count then
            return a.f.count > b.f.count
        end
        return a.idx < b.idx
    end)

    local nOrder = table.getn(order)
    if nOrder == 0 then
        table.insert(lines, "  none")
    end
    for i = 1, nOrder do
        local e = order[i]
        emitNameList(lines,
            "  " .. M.gapLabel(e.sd) .. " (" .. e.f.count .. "): ", e.f.names)
    end

    -- "Not readable" is its OWN line; unreadable players never appear in
    -- the missing lists above (UNKNOWN is never counted as MISSING).
    if vm.unreadable and vm.unreadable.count > 0 then
        emitNameList(lines,
            "Not readable (" .. vm.unreadable.count .. "): ",
            vm.unreadable.names)
    end
    -- DEBUFFS column-cap overflow: the report's slot
    -- lines cover the capped columns; the overflow is appended as one
    -- line so the report never silently hides debuff types.
    if vm.debuffMore and vm.debuffMore > 0 then
        table.insert(lines, "+" .. vm.debuffMore .. " more debuff types")
    end
    return lines
end

-- ==================================================================
-- unknownBuffs(store) -> { [buffName] = count }
-- Aura names no data table knows (no silent discard);
-- count = number of readable players carrying the aura.
-- ==================================================================
function M.unknownBuffs(store)
    local out = {}
    local players = (store and store.players) or {}
    local n = table.getn(players)
    for i = 1, n do
        local p = players[i]
        if p.aurasRead and p.auras and p.auras.names then
            for name in pairs(p.auras.names) do
                local known = (DC.CONSUMABLES and DC.CONSUMABLES[name])
                    or (DC.CLASSBUFFS and DC.CLASSBUFFS[name])
                if not known then
                    out[name] = (out[name] or 0) + 1
                end
            end
        end
    end
    return out
end
